{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "page-flip",
  "type": "registry:ui",
  "title": "Page Flip",
  "description": "A CSS 3D book: drag a page's edge and it turns around the spine with curl shading, or falls back.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/page-flip.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type PageFlipView = \"auto\" | \"spread\" | \"single\";\n\nexport interface PageFlipProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  /** Page contents, in reading order. Page 1 is the cover (shown alone on the right in spreads). */\n  pages: React.ReactNode[];\n  /** Accessible name of the book. */\n  label?: string;\n  /** Page width in px (shrinks to fit narrow containers). */\n  width?: number;\n  /** Page height as a multiple of its width. */\n  ratio?: number;\n  /** Two-page spreads, single pages, or spreads only when the container is wide enough. */\n  view?: PageFlipView;\n  /** Duration of one page turn, in ms. */\n  duration?: number;\n  /** Strength of the curl shading on turning pages, 0–1. */\n  shading?: number;\n  /** Small page numbers in the outer bottom corners. */\n  folios?: boolean;\n  /** Previous / next buttons and the counter. */\n  showControls?: boolean;\n  /** Focus ring and control colour (any CSS colour). */\n  accent?: string;\n  /** Initially shown page (0-based). */\n  defaultPage?: number;\n  /** Called with the first visible page (0-based) when it changes. */\n  onPageChange?: (page: number) => void;\n}\n\ntype Mode = \"spread\" | \"single\";\n\ninterface GripDrag {\n  id: number;\n  forward: boolean;\n  leaf: number;\n  spineX: number;\n  pageW: number;\n  x0: number;\n  angle: number;\n  t: number;\n  v: number;\n}\nconst EASE = [0.645, 0.045, 0.355, 1] as const; // ease-in-out cubic: lift, glide, settle\nconst STAGGER = 0.07; // seconds between leaves when several turn at once\n\nconst iconButton =\n  \"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-hidden transition-colors hover:border-zinc-900/25 hover:text-zinc-950 aria-disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pf-accent)] dark:border-white/12 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white\";\n\nconst paper = \"bg-[#FBFAF6] dark:bg-[#1B1E22]\";\n\nfunction Chevron({ dir }: { dir: \"left\" | \"right\" }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d={dir === \"left\" ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} />\n    </svg>\n  );\n}\n\ninterface LeafProps {\n  index: number;\n  leaves: number;\n  turned: number;\n  front: React.ReactNode;\n  back: React.ReactNode;\n  frontVisible: boolean;\n  backVisible: boolean;\n  frontFolio: number | null;\n  backFolio: number | null;\n  duration: number;\n  shading: number;\n  instant: boolean;\n  register: (index: number, value: MotionValue<number> | null) => void;\n}\n\n/**\n * One sheet of paper hinged at the spine: front face on the right, back face (pre-rotated 180°)\n * on the left. Its angle is a motion value that animates whenever the number of turned leaves\n * crosses it; the parent can also drive it directly while a corner is dragged.\n */\nfunction Leaf({ index, leaves, turned, front, back, frontVisible, backVisible, frontFolio, backFolio, duration, shading, instant, register }: LeafProps) {\n  const target = index < turned ? -180 : 0;\n  const rot = useMotionValue(target);\n  const prevTurned = React.useRef(turned);\n\n  React.useEffect(() => {\n    register(index, rot);\n    return () => register(index, null);\n  }, [index, rot, register]);\n\n  React.useEffect(() => {\n    const from = prevTurned.current;\n    prevTurned.current = turned;\n    if (instant) {\n      rot.set(target);\n      return;\n    }\n    // When several leaves turn at once (Home/End, view changes), riffle them in reading order.\n    const order = turned > from ? index - from : from - 1 - index;\n    const remaining = Math.abs(target - rot.get()) / 180;\n    const controls = animate(rot, target, {\n      duration: Math.max(0.12, (duration / 1000) * remaining),\n      ease: EASE,\n      delay: Math.max(0, order) * STAGGER,\n    });\n    return () => controls.stop();\n  }, [target, turned, index, duration, instant, rot]);\n\n  // On the right half the earliest leaf is on top; once past 90° the latest one is.\n  const zIndex = useTransform(rot, (r) => (r > -90 ? leaves - index : index + 1));\n  const lift = useTransform(rot, (r) => Math.round(Math.sin((-r * Math.PI) / 180) * shading * 1000) / 1000);\n\n  return (\n    <motion.div\n      className=\"absolute inset-y-0 left-[var(--pf-spine)] w-[var(--pf-w)] origin-left [transform-style:preserve-3d]\"\n      style={{ rotateY: rot, zIndex }}\n    >\n      {/* Front: a right-hand page. */}\n      <div\n        aria-hidden={!frontVisible}\n        inert={!frontVisible}\n        className={`absolute inset-0 overflow-hidden rounded-r-[3px] border border-zinc-900/10 [backface-visibility:hidden] dark:border-white/10 ${paper}`}\n      >\n        <div className=\"h-full w-full\">{front}</div>\n        {frontFolio !== null && (\n          <span aria-hidden=\"true\" className=\"absolute bottom-2.5 right-3 font-mono text-[9px] tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n            {String(frontFolio).padStart(2, \"0\")}\n          </span>\n        )}\n        <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgb(0_0_0/0.09),transparent_9%)]\" />\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgb(0_0_0/0.38),rgb(0_0_0/0.06)_32%,rgb(255_255_255/0.16)_68%,rgb(0_0_0/0.18))]\"\n          style={{ opacity: lift }}\n        />\n      </div>\n      {/* Back: a left-hand page, mirrored so it reads correctly once the leaf has turned. */}\n      <div\n        aria-hidden={!backVisible}\n        inert={!backVisible}\n        className={`absolute inset-0 overflow-hidden rounded-l-[3px] border border-zinc-900/10 [backface-visibility:hidden] [transform:rotateY(180deg)] dark:border-white/10 ${paper}`}\n      >\n        <div className=\"h-full w-full\">{back}</div>\n        {backFolio !== null && (\n          <span aria-hidden=\"true\" className=\"absolute bottom-2.5 left-3 font-mono text-[9px] tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n            {String(backFolio).padStart(2, \"0\")}\n          </span>\n        )}\n        <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_left,rgb(0_0_0/0.09),transparent_9%)]\" />\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_left,rgb(0_0_0/0.38),rgb(0_0_0/0.06)_32%,rgb(255_255_255/0.16)_68%,rgb(0_0_0/0.18))]\"\n          style={{ opacity: lift }}\n        />\n      </div>\n    </motion.div>\n  );\n}\n\n/**\n * A book whose pages turn around the spine in CSS 3D. Wide containers show two-page spreads,\n * narrow ones a single page. Turn with the buttons, the arrow keys while the book is focused, or\n * by dragging a page's outer edge — the page follows the pointer and completes or falls back\n * depending on how far (and how fast) it went. Only the visible pages are interactive; the rest\n * are inert and hidden from assistive tech, and the visible pages are announced.\n */\nexport function PageFlip({\n  pages,\n  label = \"Book\",\n  width = 240,\n  ratio = 1.38,\n  view = \"auto\",\n  duration = 700,\n  shading = 0.6,\n  folios = true,\n  showControls = true,\n  accent = \"#FF7448\",\n  defaultPage = 0,\n  onPageChange,\n  className = \"\",\n  style,\n  ...props\n}: PageFlipProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const n = pages.length;\n  const rootRef = React.useRef<HTMLElement>(null);\n  const bookRef = React.useRef<HTMLDivElement>(null);\n  const leafValues = React.useRef(new Map<number, MotionValue<number>>());\n  const onPageChangeRef = React.useRef(onPageChange);\n  const drag = React.useRef<GripDrag | null>(null);\n\n  const [page, setPage] = React.useState(() => Math.max(0, Math.min(n - 1, Math.round(defaultPage))));\n  const [layout, setLayout] = React.useState<{ mode: Mode; pageW: number }>({ mode: view === \"single\" ? \"single\" : \"spread\", pageW: width });\n  const [dragging, setDragging] = React.useState(false);\n\n  const mode: Mode = view === \"auto\" ? layout.mode : view;\n  const pageW = Math.round(Math.min(width, layout.pageW));\n  const pageH = Math.round(pageW * ratio);\n  const spread = mode === \"spread\";\n\n  // Leaves: in spreads each sheet carries two pages (front = right, back = left); single view\n  // gives every page its own sheet with a blank back.\n  const leafCount = spread ? Math.ceil(n / 2) : n;\n  const maxTurned = spread ? Math.floor(n / 2) : n - 1;\n  const turned = Math.min(maxTurned, spread ? Math.floor((page + 1) / 2) : page);\n  const visible = spread ? [turned * 2 - 1, turned * 2].filter((p) => p >= 0 && p < n) : [page];\n  const first = visible[0] ?? 0;\n\n  React.useEffect(() => {\n    onPageChangeRef.current = onPageChange;\n  });\n\n  // Measure the container: choose spread or single (in auto) and shrink pages to fit.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver(([entry]) => {\n      const available = entry.contentRect.width - 24;\n      const wide = available >= width * 2;\n      const nextMode: Mode = view === \"single\" || (view === \"auto\" && !wide) ? \"single\" : \"spread\";\n      const nextW = Math.max(120, Math.min(width, nextMode === \"spread\" ? available / 2 : available));\n      setLayout((prev) => (prev.mode === nextMode && Math.abs(prev.pageW - nextW) < 1 ? prev : { mode: nextMode, pageW: nextW }));\n    });\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, [width, view]);\n\n  const register = React.useCallback((index: number, value: MotionValue<number> | null) => {\n    if (value) leafValues.current.set(index, value);\n    else leafValues.current.delete(index);\n  }, []);\n\n  const goToTurned = (t: number) => {\n    const next = Math.max(0, Math.min(maxTurned, t));\n    const nextPage = spread ? (next === 0 ? 0 : next * 2 - 1) : next;\n    if (nextPage === page) return;\n    setPage(nextPage);\n    onPageChangeRef.current?.(nextPage);\n  };\n\n  /* --------------------------------- Drag --------------------------------- */\n\n  const angleAt = (d: GripDrag, clientX: number) => {\n    if (spread) {\n      // The page's outer edge follows the pointer: x = spine + w·cos θ.\n      const c = Math.max(-1, Math.min(1, (clientX - d.spineX) / d.pageW));\n      return (Math.acos(c) * 180) / Math.PI;\n    }\n    const p = Math.max(0, Math.min(1, (d.forward ? d.x0 - clientX : clientX - d.x0) / d.pageW));\n    return d.forward ? p * 180 : 180 - p * 180;\n  };\n\n  const onGripDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || !bookRef.current) return;\n    const forward = e.currentTarget.dataset.direction === \"next\";\n    const leaf = forward ? turned : turned - 1;\n    const value = leafValues.current.get(leaf);\n    if (!value) return;\n    e.preventDefault();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    value.stop();\n    const rect = bookRef.current.getBoundingClientRect();\n    const scale = rect.width / (spread ? pageW * 2 : pageW) || 1;\n    drag.current = {\n      id: e.pointerId,\n      forward,\n      leaf,\n      spineX: rect.left + (spread ? rect.width / 2 : 0),\n      pageW: pageW * scale,\n      x0: e.clientX,\n      angle: -value.get(),\n      t: e.timeStamp,\n      v: 0,\n    };\n    setDragging(true);\n  };\n\n  const onGripMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    const angle = angleAt(d, e.clientX);\n    const dt = Math.max(1, e.timeStamp - d.t) / 1000;\n    d.v = d.v * 0.5 + ((angle - d.angle) / dt) * 0.5;\n    d.angle = angle;\n    d.t = e.timeStamp;\n    leafValues.current.get(d.leaf)?.set(-angle);\n  };\n\n  const onGripUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    setDragging(false);\n    const flick = e.timeStamp - d.t > 90 ? 0 : d.v; // degrees per second\n    const complete = d.forward ? d.angle > 90 || flick > 320 : d.angle < 90 || flick < -320;\n    if (complete && Math.abs(d.angle - (d.forward ? 0 : 180)) > 2) {\n      goToTurned(d.forward ? turned + 1 : turned - 1);\n      return;\n    }\n    // Fall back to where it was.\n    const value = leafValues.current.get(d.leaf);\n    if (!value) return;\n    const home = d.forward ? 0 : -180;\n    if (reduceMotion) value.set(home);\n    else animate(value, home, { duration: Math.max(0.15, ((duration / 1000) * Math.abs(home - value.get())) / 180), ease: EASE });\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget) return;\n    const keys: Record<string, () => void> = {\n      ArrowLeft: () => goToTurned(turned - 1),\n      ArrowRight: () => goToTurned(turned + 1),\n      Home: () => goToTurned(0),\n      End: () => goToTurned(maxTurned),\n    };\n    const action = keys[e.key];\n    if (!action) return;\n    e.preventDefault();\n    action();\n  };\n\n  if (!n) return null;\n\n  // Spreads keep the visible paper centred: shift right on the cover, left on the back cover.\n  const shift = spread ? (turned === 0 ? -pageW / 2 : turned === leafCount && n % 2 === 0 ? pageW / 2 : 0) : 0;\n  const canPrev = turned > 0;\n  const canNext = turned < maxTurned;\n  const announcement =\n    visible.length === 2 ? `Pages ${visible[0] + 1}–${visible[1] + 1} of ${n}` : `Page ${first + 1} of ${n}`;\n  const grip = Math.max(36, Math.round(pageW * 0.16));\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"book\"\n      aria-label={label}\n      className={`relative w-full ${className}`}\n      style={{ [\"--pf-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        role=\"group\"\n        aria-label={`${label}: use the Left and Right arrow keys to turn pages`}\n        tabIndex={0}\n        onKeyDown={onKeyDown}\n        className=\"mx-auto w-fit rounded-md px-3 py-6 outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pf-accent)]\"\n      >\n        {/* Single view hides the turned stack to the left of the spine; nothing else is clipped. */}\n        <div style={!spread ? { clipPath: \"inset(-64px -64px -64px 0)\" } : undefined}>\n          <div\n            ref={bookRef}\n            className={`relative ${dragging ? \"select-none\" : \"\"} transition-transform duration-500 ease-[cubic-bezier(0.645,0.045,0.355,1)] motion-reduce:transition-none`}\n            style={{\n              width: spread ? pageW * 2 : pageW,\n              height: pageH,\n              perspective: pageW * 5,\n              translate: `${Math.round(shift)}px 0`,\n              [\"--pf-w\" as string]: `${pageW}px`,\n              [\"--pf-spine\" as string]: spread ? `${pageW}px` : \"0px\",\n            }}\n          >\n            {/* Page block: stacked edges under whichever side still holds paper. */}\n            {spread && canPrev && (\n              <div\n                aria-hidden=\"true\"\n                className=\"absolute inset-y-0 left-0 w-[var(--pf-w)] rounded-l-[3px] bg-[#EFEBE2] shadow-[-1px_1px_0_#E3DED3,-2px_2px_0_#D9D3C6,-3px_3px_0_#CFC8BA,0_24px_44px_-24px_rgb(0_0_0/0.45)] dark:bg-[#24282D] dark:shadow-[-1px_1px_0_#2B3036,-2px_2px_0_#30353B,-3px_3px_0_#353A41,0_24px_44px_-24px_rgb(0_0_0/0.8)]\"\n              />\n            )}\n            {turned < leafCount && (\n              <div\n                aria-hidden=\"true\"\n                className=\"absolute inset-y-0 left-[var(--pf-spine)] w-[var(--pf-w)] rounded-r-[3px] bg-[#EFEBE2] shadow-[1px_1px_0_#E3DED3,2px_2px_0_#D9D3C6,3px_3px_0_#CFC8BA,0_24px_44px_-24px_rgb(0_0_0/0.45)] dark:bg-[#24282D] dark:shadow-[1px_1px_0_#2B3036,2px_2px_0_#30353B,3px_3px_0_#353A41,0_24px_44px_-24px_rgb(0_0_0/0.8)]\"\n              />\n            )}\n\n            {Array.from({ length: leafCount }, (_, j) => {\n              const frontPage = spread ? j * 2 : j;\n              const backPage = spread ? j * 2 + 1 : -1;\n              const hasBack = backPage >= 0 && backPage < n;\n              return (\n                <Leaf\n                  // Re-create sheets when the view changes, so they start at their resting angle.\n                  key={`${mode}-${j}`}\n                  index={j}\n                  leaves={leafCount}\n                  turned={turned}\n                  front={pages[frontPage]}\n                  back={hasBack ? pages[backPage] : null}\n                  frontVisible={visible.includes(frontPage)}\n                  backVisible={hasBack && visible.includes(backPage)}\n                  frontFolio={folios ? frontPage + 1 : null}\n                  backFolio={folios && hasBack ? backPage + 1 : null}\n                  duration={duration}\n                  shading={Math.max(0, Math.min(1, shading))}\n                  instant={reduceMotion}\n                  register={register}\n                />\n              );\n            })}\n\n            {/* Corner grips: drag a page's outer edge to turn it (pointer only; keys and buttons do the same). */}\n            {canNext && (\n              <div\n                aria-hidden=\"true\"\n                data-direction=\"next\"\n              onPointerDown={onGripDown}\n                onPointerMove={onGripMove}\n                onPointerUp={onGripUp}\n                onPointerCancel={onGripUp}\n                className=\"group absolute inset-y-0 right-0 z-[500] cursor-grab touch-pan-y active:cursor-grabbing\"\n                style={{ width: grip }}\n              >\n                <span className=\"absolute bottom-0 right-0 size-7 rounded-tl-[10px] bg-[linear-gradient(135deg,transparent_45%,rgb(0_0_0/0.08)_50%,#F2EEE6_52%,#E4DFD4)] opacity-0 shadow-[-2px_-2px_6px_rgb(0_0_0/0.08)] transition-opacity duration-200 group-hover:opacity-100 dark:bg-[linear-gradient(135deg,transparent_45%,rgb(0_0_0/0.3)_50%,#2A2E34_52%,#33383F)]\" />\n              </div>\n            )}\n            {canPrev && (\n              <div\n                aria-hidden=\"true\"\n                data-direction=\"previous\"\n              onPointerDown={onGripDown}\n                onPointerMove={onGripMove}\n                onPointerUp={onGripUp}\n                onPointerCancel={onGripUp}\n                className=\"group absolute inset-y-0 left-0 z-[500] cursor-grab touch-pan-y active:cursor-grabbing\"\n                style={{ width: grip }}\n              >\n                {spread && (\n                  <span className=\"absolute bottom-0 left-0 size-7 rounded-tr-[10px] bg-[linear-gradient(225deg,transparent_45%,rgb(0_0_0/0.08)_50%,#F2EEE6_52%,#E4DFD4)] opacity-0 shadow-[2px_-2px_6px_rgb(0_0_0/0.08)] transition-opacity duration-200 group-hover:opacity-100 dark:bg-[linear-gradient(225deg,transparent_45%,rgb(0_0_0/0.3)_50%,#2A2E34_52%,#33383F)]\" />\n                )}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n\n      {showControls && (\n        <div className=\"mt-2 flex items-center justify-center gap-2\">\n          <button type=\"button\" aria-label=\"Previous page\" aria-disabled={!canPrev} onClick={() => goToTurned(turned - 1)} className={iconButton}>\n            <Chevron dir=\"left\" />\n          </button>\n          <span aria-hidden=\"true\" className=\"min-w-[8rem] text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            <span className=\"text-zinc-900 dark:text-zinc-100\">{visible.map((p) => String(p + 1).padStart(2, \"0\")).join(\"–\")}</span> / {String(n).padStart(2, \"0\")}\n          </span>\n          <button type=\"button\" aria-label=\"Next page\" aria-disabled={!canNext} onClick={() => goToTurned(turned + 1)} className={iconButton}>\n            <Chevron dir=\"right\" />\n          </button>\n        </div>\n      )}\n\n      <div className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/page-flip"
}
