Baktiyar Ablaikhan

Page peel

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Drag the page corner to peel it back; pull far left to turn the page.

Code

"use client";

import { animate, useMotionValue, useMotionValueEvent } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { Surface, useSize } from "../ui";

type P = { x: number; y: number };

/** Keep the part of the polygon where (X - m)·n <= 0 (side = -1) or >= 0 (side = 1). */
function clip(poly: P[], m: P, n: P, side: 1 | -1) {
  const f = (p: P) => side * ((p.x - m.x) * n.x + (p.y - m.y) * n.y);
  const out: P[] = [];
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i];
    const b = poly[(i + 1) % poly.length];
    const fa = f(a);
    const fb = f(b);
    if (fa >= 0) out.push(a);
    if ((fa >= 0) !== (fb >= 0)) {
      const t = fa / (fa - fb);
      out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
    }
  }
  return out;
}

const PAGES = [
  { kicker: "Chapter one", title: "The quiet room", body: "Peel the corner to turn the page." },
  { kicker: "Chapter two", title: "Under the fold", body: "Every page hides another one." },
  { kicker: "Chapter three", title: "Paper weather", body: "Drag far to the left to turn it." },
];

function PageFace({ i, tone }: { i: number; tone: "front" | "back" }) {
  const page = PAGES[i % PAGES.length];
  return (
    <div className={`absolute inset-0 flex flex-col justify-between p-[1.4em] ${tone === "front" ? "bg-white" : "bg-[#fbfaf7]"}`}>
      <div>
        <p className="font-mono text-[0.65em] uppercase tracking-[0.18em] text-[#2B46B8]">{page.kicker}</p>
        <p className="mt-[0.3em] font-serif text-[1.7em] leading-[1.05]">{page.title}</p>
      </div>
      <div className="space-y-[0.45em]">
        {[92, 100, 84, 96, 60].map((w, k) => (
          <div key={k} className="h-[0.32em] rounded-full bg-[#e9e8e3]" style={{ width: `${w}%` }} />
        ))}
      </div>
      <p className="text-[0.75em] text-[#8a8984]">{page.body}</p>
    </div>
  );
}

export default function PagePeel() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const box = useRef<HTMLDivElement>(null);
  const { w, h } = useSize(box);
  const cx = useMotionValue(-1);
  const cy = useMotionValue(-1);
  const [corner, setCorner] = useState<P>({ x: -1, y: -1 });
  const [page, setPage] = useState(0);
  const dragging = useRef(false);
  const busy = useRef(false);

  useMotionValueEvent(cx, "change", (x) => setCorner((c) => ({ ...c, x })));
  useMotionValueEvent(cy, "change", (y) => setCorner((c) => ({ ...c, y })));

  const rest = (W: number, H: number) => ({ x: W - Math.min(W, H) * 0.14, y: H - Math.min(W, H) * 0.14 });

  useEffect(() => {
    if (!w || !h || busy.current || dragging.current) return;
    const r = rest(w, h);
    cx.jump(r.x);
    cy.jump(r.y);
  }, [w, h, cx, cy]);

  const springTo = (x: number, y: number, stiff = 260) => {
    const t = { type: "spring" as const, stiffness: stiff, damping: 26 };
    return Promise.all([animate(cx, x, t), animate(cy, y, t)]);
  };

  const local = (e: React.PointerEvent) => {
    const r = box.current!.getBoundingClientRect();
    return { x: Math.min(e.clientX - r.left, w - 1), y: Math.min(e.clientY - r.top, h - 1) };
  };

  const onDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (busy.current) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    dragging.current = true;
    const p = local(e);
    springTo(p.x, p.y, 500);
  };
  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (busy.current || !w) return;
    const p = local(e);
    if (dragging.current) {
      cx.jump(p.x);
      cy.jump(p.y);
      return;
    }
    // hover near the corner lifts it a little
    const d = Math.hypot(w - p.x, h - p.y);
    const r = rest(w, h);
    if (d < Math.min(w, h) * 0.45) springTo(r.x - (w - p.x) * 0.25 - 10, r.y - (h - p.y) * 0.25 - 10, 300);
    else springTo(r.x, r.y);
  };
  const onUp = async () => {
    if (!dragging.current) return;
    dragging.current = false;
    if (cx.get() < w * 0.35) {
      busy.current = true;
      await springTo(-w * 1.05, h * 0.9, 120);
      setPage((p) => p + 1);
      const r = rest(w, h);
      cx.jump(w);
      cy.jump(h);
      await springTo(r.x, r.y);
      busy.current = false;
    } else {
      const r = rest(w, h);
      springTo(r.x, r.y);
    }
  };
  const onLeave = () => {
    if (dragging.current || busy.current || !w) return;
    const r = rest(w, h);
    springTo(r.x, r.y);
  };

  let frontClip = "none";
  let flap = "";
  let shadow = "";
  if (w && h && corner.y >= 0) {
    const C = { x: w, y: h };
    const P = { x: Math.min(corner.x, w - 0.5), y: Math.min(corner.y, h - 0.5) };
    const dx = C.x - P.x;
    const dy = C.y - P.y;
    const len = Math.hypot(dx, dy) || 1;
    const n = { x: dx / len, y: dy / len };
    const m = { x: (C.x + P.x) / 2, y: (C.y + P.y) / 2 };
    const rect = [
      { x: 0, y: 0 },
      { x: w, y: 0 },
      { x: w, y: h },
      { x: 0, y: h },
    ];
    const keep = clip(rect, m, n, -1);
    const fold = clip(rect, m, n, 1).map((p) => {
      const d = (p.x - m.x) * n.x + (p.y - m.y) * n.y;
      return { x: p.x - 2 * d * n.x, y: p.y - 2 * d * n.y };
    });
    frontClip = `polygon(${keep.map((p) => `${p.x}px ${p.y}px`).join(",")})`;
    flap = fold.map((p) => `${p.x},${p.y}`).join(" ");
    // a soft shadow on the revealed page, along the fold
    const sh = clip(rect, { x: m.x - n.x * 14, y: m.y - n.y * 14 }, n, 1);
    shadow = sh.map((p) => `${p.x},${p.y}`).join(" ");
  }

  return (
    <Surface>
      <div
        ref={box}
        onPointerDown={onDown}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onPointerCancel={onUp}
        onPointerLeave={onLeave}
        className="relative h-[16em] w-[24em] max-w-[86%] cursor-grab touch-none rounded-[0.2em] shadow-[0_14px_40px_-18px_rgba(0,0,0,0.35),0_0_0_1px_rgba(0,0,0,0.04)] active:cursor-grabbing"
      >
        <PageFace i={page + 1} tone="back" />
        <svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible">
          {shadow && <polygon points={shadow} fill="#000" opacity={0.06} />}
        </svg>
        <div className="absolute inset-0" style={{ clipPath: frontClip }}>
          <PageFace i={page} tone="front" />
        </div>
        <svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible">
          <defs>
            <linearGradient id={`bencho-flap-${uid}`} x1="1" y1="1" x2="0" y2="0">
              <stop offset="0" stopColor="#ffffff" />
              <stop offset="0.6" stopColor="#efeee9" />
              <stop offset="1" stopColor="#d8d7d1" />
            </linearGradient>
            <filter id={`bencho-drop-${uid}`} x="-50%" y="-50%" width="200%" height="200%">
              <feDropShadow dx="-2" dy="-1" stdDeviation="4" floodOpacity="0.18" />
            </filter>
          </defs>
          {flap && <polygon points={flap} fill={`url(#bencho-flap-${uid})`} filter={`url(#bencho-drop-${uid})`} />}
        </svg>
      </div>
    </Surface>
  );
}
Prompt

Build "Page peel", a card animation. What it does: Drag the page corner to peel it back; pull far left to turn the page. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).