{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "paper-fold",
  "type": "registry:ui",
  "title": "Paper Fold",
  "description": "A panel that folds and unfolds like paper: 3D accordion creases with shading, toggled or scrubbed.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/paper-fold.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from \"motion/react\";\n\nexport type PaperFoldDirection = \"vertical\" | \"horizontal\";\n\nexport interface PaperFoldProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The paper's content. Readable and interactive only when fully unfolded. */\n  children: React.ReactNode;\n  /** Number of accordion segments (2–12). */\n  folds?: number;\n  /** Fold / unfold duration in ms. */\n  duration?: number;\n  /** Fold along the height (vertical) or the width (horizontal). */\n  direction?: PaperFoldDirection;\n  /** Strength of the crease shading, 0–1. */\n  shading?: number;\n  /** Let the toggle be dragged to scrub the fold by hand. */\n  scrub?: boolean;\n  /** Toggle focus ring and grip colour. */\n  accent?: string;\n  /** Controlled state. */\n  expanded?: boolean;\n  /** Initial state (uncontrolled). */\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Toggle label while folded. */\n  openLabel?: string;\n  /** Toggle label while unfolded. */\n  closeLabel?: string;\n  /** Classes for the paper surface (applied to every segment and to the unfolded content). */\n  paperClassName?: string;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\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\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Constants -------------------------------- */\n\n/** Fold angle of each segment when fully folded. Below 90° the zigzag stays faintly visible. */\nconst MAX_ANGLE = 80;\nconst RAD = Math.PI / 180;\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\nconst EASE = [0.65, 0, 0.35, 1] as const;\n\n/* --------------------------------- Segment --------------------------------- */\n\ninterface SegmentProps {\n  index: number;\n  count: number;\n  /** Segment length along the fold axis, in px. */\n  size: number;\n  /** Content box: width and height of the unfolded paper. */\n  width: number;\n  height: number;\n  vertical: boolean;\n  shading: number;\n  progress: MotionValue<number>;\n  paperClassName: string;\n  children: React.ReactNode;\n}\n\n/**\n * One accordion panel. Panels are nested so each one hinges on the far edge of the previous\n * one: the first tilts by φ, every following one by ±2φ relative to its parent, which gives\n * absolute angles alternating +φ / −φ — a zigzag.\n */\nfunction Segment({ index, count, size, width, height, vertical, shading, progress, paperClassName, children }: SegmentProps) {\n  const sign = (index % 2 === 0 ? -1 : 1) * (vertical ? 1 : -1);\n  const factor = index === 0 ? 1 : 2;\n  const rotate = useTransform(progress, (p) => sign * factor * (1 - p) * MAX_ANGLE);\n  // Panels tilted away from the light (every other one) get the darker crease.\n  const shade = useTransform(progress, (p) => shading * Math.sin((1 - p) * MAX_ANGLE * RAD) * (index % 2 === 0 ? 0.45 : 0.9));\n  const gradient = vertical\n    ? `linear-gradient(${index % 2 === 0 ? \"to bottom\" : \"to top\"}, rgba(0,0,0,0.05), rgba(0,0,0,0.75))`\n    : `linear-gradient(${index % 2 === 0 ? \"to right\" : \"to left\"}, rgba(0,0,0,0.05), rgba(0,0,0,0.75))`;\n\n  return (\n    <motion.div\n      className={index === 0 ? \"absolute left-0 top-0\" : vertical ? \"absolute left-0 top-full\" : \"absolute left-full top-0\"}\n      style={{\n        width: vertical ? width : size,\n        height: vertical ? size : height,\n        transformStyle: \"preserve-3d\",\n        transformOrigin: vertical ? \"50% 0%\" : \"0% 50%\",\n        rotateX: vertical ? rotate : 0,\n        rotateY: vertical ? 0 : rotate,\n      }}\n    >\n      <div className={`absolute inset-0 overflow-hidden ${paperClassName}`}>\n        <div\n          style={{\n            width,\n            height,\n            transform: vertical ? `translateY(${-index * size}px)` : `translateX(${-index * size}px)`,\n          }}\n        >\n          {children}\n        </div>\n        <motion.div className=\"absolute inset-0\" style={{ background: gradient, opacity: shade }} />\n      </div>\n      {index + 1 < count && (\n        <Segment\n          index={index + 1}\n          count={count}\n          size={size}\n          width={width}\n          height={height}\n          vertical={vertical}\n          shading={shading}\n          progress={progress}\n          paperClassName={paperClassName}\n        >\n          {children}\n        </Segment>\n      )}\n    </motion.div>\n  );\n}\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * A panel that folds and unfolds like a sheet of paper. While folding, the content is drawn as\n * N clipped, aria-hidden copies on nested 3D segments (rotateX / rotateY) with crease shading;\n * once fully unfolded, the single real copy is shown, crisp and interactive. A button with\n * aria-expanded toggles it, and can be dragged to scrub the fold by hand.\n */\nexport function PaperFold({\n  children,\n  folds = 5,\n  duration = 900,\n  direction = \"vertical\",\n  shading = 0.5,\n  scrub = true,\n  accent = \"#FF7448\",\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  openLabel = \"Unfold\",\n  closeLabel = \"Fold\",\n  paperClassName = \"bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100\",\n  className = \"\",\n  style,\n  ...props\n}: PaperFoldProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const vertical = direction !== \"horizontal\";\n  const count = Math.max(2, Math.min(12, Math.round(folds)));\n  const contentId = React.useId();\n\n  const [internal, setInternal] = React.useState(defaultExpanded);\n  const expanded = expandedProp ?? internal;\n  const setExpanded = (next: boolean) => {\n    if (next === expanded) return;\n    if (expandedProp === undefined) setInternal(next);\n    onExpandedChange?.(next);\n  };\n\n  const progress = useMotionValue(expanded ? 1 : 0);\n  const [atOpen, setAtOpen] = React.useState(expanded);\n  useMotionValueEvent(progress, \"change\", (p) => setAtOpen(p >= 0.999));\n  const showReal = expanded && atOpen;\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const [box, setBox] = React.useState({ w: 0, h: 0 });\n\n  // Measure the available width (root) and the content's natural height.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const content = contentRef.current;\n    if (!root || !content) return;\n    const measure = () => {\n      const w = Math.round(root.getBoundingClientRect().width);\n      const h = Math.round(content.getBoundingClientRect().height);\n      setBox((b) => (b.w === w && b.h === h ? b : { w, h }));\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    ro.observe(content);\n    return () => ro.disconnect();\n  }, []);\n\n  // The paper's footprint shrinks with the cosine of the fold angle.\n  const extent = useMotionValue<number | string>(\"auto\");\n  React.useEffect(() => {\n    const full = vertical ? box.h : box.w;\n    const update = (p: number) => extent.set(full ? full * Math.cos((1 - p) * MAX_ANGLE * RAD) : \"auto\");\n    update(progress.get());\n    return progress.on(\"change\", update);\n  }, [box, vertical, progress, extent]);\n\n  // Animate toward the current state (skipped while the user is scrubbing).\n  const dragRef = React.useRef<{ id: number; start: number; from: number; moved: boolean } | null>(null);\n  const suppressClick = React.useRef(false);\n  React.useEffect(() => {\n    if (dragRef.current?.moved) return;\n    const controls = animate(progress, expanded ? 1 : 0, {\n      duration: reduceMotion ? 0 : Math.max(0, duration) / 1000,\n      ease: EASE,\n    });\n    return () => controls.stop();\n  }, [expanded, duration, reduceMotion, progress]);\n\n  const length = vertical ? box.h : box.w;\n\n  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (!scrub || !length || e.button !== 0) return;\n    dragRef.current = { id: e.pointerId, start: vertical ? e.clientY : e.clientX, from: progress.get(), moved: false };\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const drag = dragRef.current;\n    if (!drag || drag.id !== e.pointerId) return;\n    const delta = (vertical ? e.clientY : e.clientX) - drag.start;\n    if (!drag.moved && Math.abs(delta) < 5) return;\n    if (!drag.moved) {\n      drag.moved = true;\n      e.currentTarget.setPointerCapture(e.pointerId);\n    }\n    // Roughly track the paper edge: the folded footprint grows with the unfolded length.\n    progress.set(clamp01(drag.from + delta / (length * 0.85)));\n  };\n  const endDrag = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const drag = dragRef.current;\n    if (!drag || drag.id !== e.pointerId) return;\n    dragRef.current = null;\n    if (!drag.moved) return;\n    suppressClick.current = true;\n    const target = progress.get() > 0.5;\n    if (target !== expanded) setExpanded(target);\n    else\n      animate(progress, target ? 1 : 0, {\n        duration: reduceMotion ? 0 : (Math.max(0, duration) / 1000) * 0.5,\n        ease: EASE,\n      });\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`flex w-full flex-col ${vertical ? \"items-stretch\" : \"items-start\"} gap-3 ${className}`}\n      style={{ [\"--pf-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <motion.div\n        className=\"relative\"\n        style={{\n          width: vertical ? \"100%\" : extent,\n          height: vertical ? extent : box.h || \"auto\",\n          perspective: 1600,\n          perspectiveOrigin: vertical ? \"50% 0%\" : \"0% 50%\",\n        }}\n      >\n        <div\n          ref={contentRef}\n          id={contentId}\n          inert={!showReal}\n          className={`${showReal ? \"relative\" : \"absolute left-0 top-0 invisible\"} ${paperClassName}`}\n          style={{ width: box.w || \"100%\" }}\n        >\n          {children}\n        </div>\n        {!showReal && box.w > 0 && box.h > 0 && (\n          <div aria-hidden=\"true\" inert className=\"pointer-events-none absolute inset-0\" style={{ transformStyle: \"preserve-3d\" }}>\n            <Segment\n              index={0}\n              count={count}\n              size={length / count}\n              width={box.w}\n              height={box.h}\n              vertical={vertical}\n              shading={Math.min(1, Math.max(0, shading))}\n              progress={progress}\n              paperClassName={paperClassName}\n            >\n              {children}\n            </Segment>\n          </div>\n        )}\n      </motion.div>\n\n      <button\n        type=\"button\"\n        aria-expanded={expanded}\n        aria-controls={contentId}\n        onClick={() => {\n          if (suppressClick.current) {\n            suppressClick.current = false;\n            return;\n          }\n          setExpanded(!expanded);\n        }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className={`inline-flex h-9 select-none items-center gap-2 self-start rounded-full border border-zinc-900/10 bg-white px-3.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.05)] transition-colors hover:border-zinc-900/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--pf-accent)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 ${scrub ? (vertical ? \"cursor-ns-resize touch-none\" : \"cursor-ew-resize touch-none\") : \"\"}`}\n      >\n        {scrub && (\n          <span aria-hidden=\"true\" className={`flex gap-[3px] ${vertical ? \"flex-col\" : \"flex-row\"}`}>\n            <span className={`${vertical ? \"h-px w-3\" : \"h-3 w-px\"} bg-[var(--pf-accent)]`} />\n            <span className={`${vertical ? \"h-px w-3\" : \"h-3 w-px\"} bg-[var(--pf-accent)]`} />\n          </span>\n        )}\n        {expanded ? closeLabel : openLabel}\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 12 12\"\n          className={`h-3 w-3 transition-transform duration-300 motion-reduce:transition-none ${vertical ? (expanded ? \"rotate-180\" : \"\") : expanded ? \"rotate-90\" : \"-rotate-90\"}`}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.4\"\n        >\n          <path d=\"M3 4.5 6 7.5 9 4.5\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/paper-fold"
}
