{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"zipper-reveal","type":"registry:component","title":"Zipper reveal","description":"Unzip the pouch to reveal the code inside.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/zipper-reveal.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  animate,\n  useMotionValue,\n  useMotionValueEvent,\n  useSpring,\n  useTransform,\n  motion,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Everything is drawn in a 360 x 280 viewBox that scales with the card.\nconst W = 360;\nconst H = 280;\nconst CX = W / 2;\n// The slider's travel. Closed it parks just under the top edge; fully open\n// it stops where its 42 tall tab still clears the bottom edge.\nconst P_MIN = 26;\nconst P_MAX = H - 44;\n// Teeth part at the top of the slider, 10 above its pivot.\nconst SLIDER_TOP = 10;\n// Half the widest gap, and how far above the slider the gap takes to open\n// fully. A longer run gives the soft V a real zipper makes.\nconst MAX_HALF = 110;\nconst OPEN_RUN = 130;\n// The first 48 of travel only loosens the top, so a tiny pull doesn't\n// flare the whole pouch open.\nconst RAMP = 48;\nconst TOOTH_PITCH = 7;\n// The zip ends at the bottom stop, just under the fully open slider.\nconst ZIP_END = P_MAX + 8;\nconst TOOTH_COUNT = Math.floor((ZIP_END - 3) / TOOTH_PITCH);\nconst TAPE = 7;\nconst STEP = 0.1;\n// Copying only makes sense once the button is fully in the gap.\nconst REVEAL_AT = 0.9;\nconst SETTLE = { type: \"spring\", stiffness: 500, damping: 50 } as const;\n\n// Physical materials, the same in both themes the way a real pouch is:\n// indigo canvas, a darker lining, brass hardware, cream thread, a\n// leather pull and a paper voucher with near-black ink.\nconst FABRIC = \"oklch(0.43 0.075 258)\";\nconst LINING = \"oklch(0.24 0.04 258)\";\nconst TAPE_FILL = \"oklch(0.27 0.045 258)\";\nconst THREAD = \"oklch(0.9 0.045 85)\";\nconst BRASS_LIGHT = \"oklch(0.9 0.09 92)\";\nconst BRASS_DARK = \"oklch(0.62 0.1 72)\";\nconst LEATHER = \"oklch(0.36 0.06 48)\";\nconst CARD = \"oklch(0.975 0.012 90)\";\nconst CARD_INK = \"oklch(0.24 0.01 260)\";\n\nconst clamp = (v: number, min: number, max: number) =>\n  Math.min(Math.max(v, min), max);\n\n// Half-width of the gap at height y with the slider at p, and its slope.\nfunction gap(y: number, p: number) {\n  const d = p - SLIDER_TOP - y;\n  if (d <= 0) return { h: 0, slope: 0 };\n  const r = clamp((p - P_MIN) / RAMP, 0, 1);\n  const t = Math.min(d / OPEN_RUN, 1);\n  return {\n    h: MAX_HALF * r * (1 - (1 - t) ** 2),\n    slope: t < 1 ? (MAX_HALF * r * 2 * (1 - t)) / OPEN_RUN : 0,\n  };\n}\n\nfunction edge(p: number) {\n  const points: [number, number][] = [];\n  const end = p - SLIDER_TOP;\n  for (let y = 0; y < end; y += 6) points.push([gap(y, p).h, y]);\n  points.push([0, end]);\n  return points;\n}\n\nfunction flapPath(p: number, side: 1 | -1) {\n  const outer = side === -1 ? 0 : W;\n  const pts = edge(p)\n    .map(([h, y]) => `L${CX + side * h} ${y}`)\n    .join(\"\");\n  return `M${outer} 0${pts}L${CX} ${H}L${outer} ${H}Z`;\n}\n\nfunction tapePath(p: number, side: 1 | -1) {\n  const pts = edge(p);\n  const inner = pts.map(([h, y]) => `${CX + side * h} ${y}`);\n  const outer = pts.map(([h, y]) => `${CX + side * (h + TAPE)} ${y}`).reverse();\n  return `M${inner.join(\"L\")}L${CX} ${ZIP_END}L${CX + side * TAPE} ${ZIP_END}L${outer.join(\"L\")}Z`;\n}\n\n// Each tooth stays square to its tape, so as the tape leans away from the\n// seam the teeth tilt apart with it.\nfunction toothTransform(i: number, side: 1 | -1, p: number) {\n  const y = 3 + i * TOOTH_PITCH + (side === 1 ? TOOTH_PITCH / 2 : 0);\n  const { h, slope } = gap(y, p);\n  const angle = (Math.atan(slope) * 180) / Math.PI;\n  return `translate(${CX + side * h} ${y}) rotate(${side * angle})`;\n}\n\nfunction openness(p: number) {\n  return (p - P_MIN) / (P_MAX - P_MIN);\n}\n\nfunction describe(p: number) {\n  const pct = Math.round(openness(p) * 100);\n  return pct === 0 ? \"Zipped\" : pct === 100 ? \"Open\" : `Open ${pct}%`;\n}\n\nexport function ZipperReveal({\n  label = \"Voucher\",\n  secret = \"LAB-7Q2X\",\n  note = \"20% off\",\n  className,\n}: {\n  label?: string;\n  secret?: string;\n  note?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  // SVG ids must be plain: React's ids carry colons or guillemets.\n  const uid = `zip${useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n  const p = useMotionValue(P_MIN);\n  const [revealed, setRevealed] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const glide = useRef<AnimationPlaybackControls>(undefined);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const handleRef = useRef<HTMLDivElement>(null);\n  const columnRef = useRef<HTMLDivElement>(null);\n  const readoutRef = useRef<HTMLSpanElement>(null);\n  const flapL = useRef<SVGPathElement>(null);\n  const flapR = useRef<SVGPathElement>(null);\n  const tapeL = useRef<SVGPathElement>(null);\n  const tapeR = useRef<SVGPathElement>(null);\n  const teeth = useRef<(SVGRectElement | null)[]>([]);\n  const slider = useRef<SVGGElement>(null);\n  const tab = useRef<SVGGElement>(null);\n  const drag = useRef<{\n    id: number;\n    startY: number;\n    startP: number;\n    lastY: number;\n    lastT: number;\n    v: number;\n  } | null>(null);\n\n  // The tab points at your finger while you pull, then swings back to\n  // hanging straight: a spring with a little bounce, like a pendulum.\n  const lean = useMotionValue(0);\n  const swing = useSpring(lean, { stiffness: 260, damping: 12 });\n  useMotionValueEvent(swing, \"change\", (a) =>\n    tab.current?.setAttribute(\"transform\", `rotate(${a})`),\n  );\n\n  // The content comes into focus as the gap opens: sharp by 60%.\n  const blur = useTransform(p, (v) => {\n    const b = 4 * clamp(1 - openness(v) / 0.6, 0, 1);\n    return b > 0.01 ? `blur(${b}px)` : \"none\";\n  });\n\n  useEffect(\n    () => () => {\n      clearTimeout(copyTimer.current);\n      glide.current?.stop();\n    },\n    [],\n  );\n\n  useMotionValueEvent(p, \"change\", (v) => {\n    flapL.current?.setAttribute(\"d\", flapPath(v, -1));\n    flapR.current?.setAttribute(\"d\", flapPath(v, 1));\n    tapeL.current?.setAttribute(\"d\", tapePath(v, -1));\n    tapeR.current?.setAttribute(\"d\", tapePath(v, 1));\n    for (let i = 0; i < TOOTH_COUNT; i++) {\n      teeth.current[i * 2]?.setAttribute(\"transform\", toothTransform(i, -1, v));\n      teeth.current[i * 2 + 1]?.setAttribute(\n        \"transform\",\n        toothTransform(i, 1, v),\n      );\n    }\n    slider.current?.setAttribute(\"transform\", `translate(${CX} ${v})`);\n    const text = describe(v);\n    if (readoutRef.current) readoutRef.current.textContent = text;\n    if (columnRef.current)\n      columnRef.current.style.transform = `translateY(${(v / H) * 100}%)`;\n    const handle = handleRef.current;\n    if (handle) {\n      handle.setAttribute(\n        \"aria-valuenow\",\n        String(Math.round(openness(v) * 100)),\n      );\n      handle.setAttribute(\"aria-valuetext\", text);\n    }\n    const open = openness(v) >= REVEAL_AT;\n    if (open !== revealed) setRevealed(open);\n  });\n\n  const moveTo = (target: number) => {\n    glide.current?.stop();\n    const to = clamp(target, P_MIN, P_MAX);\n    if (reduceMotion) p.set(to);\n    else glide.current = animate(p, to, SETTLE);\n  };\n\n  const scale = () => W / (boxRef.current?.offsetWidth || W);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard?.writeText(secret);\n      setCopied(true);\n      clearTimeout(copyTimer.current);\n      copyTimer.current = setTimeout(() => setCopied(false), 1600);\n    } catch {\n      // Nothing to undo: the code stays on screen to copy by hand.\n    }\n  };\n\n  return (\n    <div className={cn(\"w-[min(440px,100%)]\", className)}>\n      <div\n        ref={boxRef}\n        className=\"relative w-full overflow-hidden rounded-[22px] shadow-raised\"\n        style={{ aspectRatio: `${W} / ${H}`, background: LINING }}\n      >\n        {/* Underneath: the lining, shadowed under the flaps, with a\n            voucher tucked into the pouch. */}\n        <motion.div\n          className=\"absolute inset-0 flex justify-center pt-[6%] shadow-[inset_0_10px_24px_oklch(0_0_0/0.45)]\"\n          style={{ filter: blur }}\n          inert={!revealed}\n        >\n          <div\n            className={cn(\n              \"h-fit w-[min(212px,56%)] rounded-[14px] px-3.5 pt-3 pb-3.5 transition-[translate,rotate,box-shadow] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n              revealed\n                ? \"-translate-y-1 rotate-0 shadow-[0_10px_24px_-6px_oklch(0_0_0/0.55)]\"\n                : \"translate-y-1 -rotate-2 shadow-[0_2px_6px_oklch(0_0_0/0.4)]\",\n            )}\n            style={{ background: CARD, color: CARD_INK }}\n          >\n            <div className=\"flex items-baseline justify-between gap-2 text-xs font-medium whitespace-nowrap\">\n              <span className=\"truncate tracking-wide uppercase opacity-60\">\n                {label}\n              </span>\n              <span className=\"font-semibold\">{note}</span>\n            </div>\n            <div className=\"mt-1 font-mono text-[22px] leading-8 font-semibold tracking-wider\">\n              {secret}\n            </div>\n            {/* Perforation, with the two notches a ticket punch leaves. */}\n            <div className=\"relative -mx-3.5 my-2.5 border-t border-dashed border-current/25\">\n              <span\n                className=\"absolute -top-1.5 -left-1.5 size-3 rounded-full\"\n                style={{ background: LINING }}\n              />\n              <span\n                className=\"absolute -top-1.5 -right-1.5 size-3 rounded-full\"\n                style={{ background: LINING }}\n              />\n            </div>\n            <button\n              type=\"button\"\n              onClick={copy}\n              className=\"h-9 w-full touch-manipulation rounded-[8px] text-sm font-medium outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-current active:scale-[0.96]\"\n              style={{ background: CARD_INK, color: CARD }}\n            >\n              <span className=\"grid\">\n                <span\n                  className={cn(\n                    \"col-start-1 row-start-1 transition-[opacity,filter] duration-200 ease-out\",\n                    copied && \"opacity-0 blur-[4px]\",\n                  )}\n                >\n                  Copy code\n                </span>\n                <span\n                  className={cn(\n                    \"col-start-1 row-start-1 transition-[opacity,filter] duration-200 ease-out\",\n                    !copied && \"opacity-0 blur-[4px]\",\n                  )}\n                >\n                  Copied\n                </span>\n              </span>\n            </button>\n          </div>\n        </motion.div>\n\n        <svg\n          aria-hidden\n          viewBox={`0 0 ${W} ${H}`}\n          className=\"pointer-events-none absolute inset-0 size-full\"\n        >\n          <defs>\n            {/* A plain canvas weave: faint light warp, faint dark weft. */}\n            <pattern\n              id={`${uid}w`}\n              width={3}\n              height={3}\n              patternUnits=\"userSpaceOnUse\"\n            >\n              <rect width={3} height={1} fill=\"oklch(1 0 0 / 0.07)\" />\n              <rect width={1} height={3} fill=\"oklch(0 0 0 / 0.12)\" />\n            </pattern>\n            <linearGradient id={`${uid}b`} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">\n              <stop offset=\"0\" stopColor={BRASS_LIGHT} />\n              <stop offset=\"1\" stopColor={BRASS_DARK} />\n            </linearGradient>\n            <path id={`${uid}l`} ref={flapL} d={flapPath(P_MIN, -1)} />\n            <path id={`${uid}r`} ref={flapR} d={flapPath(P_MIN, 1)} />\n          </defs>\n          {/* The flaps, shading the lining where they lift off it. */}\n          <g className=\"[filter:drop-shadow(0_2px_4px_oklch(0_0_0/0.45))]\">\n            <use href={`#${uid}l`} fill={FABRIC} />\n            <use href={`#${uid}r`} fill={FABRIC} />\n          </g>\n          <use href={`#${uid}l`} fill={`url(#${uid}w)`} />\n          <use href={`#${uid}r`} fill={`url(#${uid}w)`} />\n          {/* Light falls from above: a soft sheen across the top. */}\n          <rect\n            width={W}\n            height={H}\n            fill=\"none\"\n            stroke=\"oklch(1 0 0 / 0.12)\"\n            strokeWidth={2}\n            rx={20}\n          />\n          {/* Stitching around the three closed sides, in cream thread. */}\n          <path\n            d={`M14 30 V${H - 30} Q14 ${H - 14} 30 ${H - 14} H${W - 30} Q${W - 14} ${H - 14} ${W - 14} ${H - 30} V30`}\n            fill=\"none\"\n            stroke={THREAD}\n            strokeWidth={1.2}\n            strokeDasharray=\"5 3.5\"\n            strokeLinecap=\"round\"\n          />\n          {/* A woven maker's label sewn into the seam. */}\n          <g transform={`translate(34 ${H - 44})`}>\n            <rect width={46} height={16} rx={2} fill={THREAD} />\n            <text\n              x={23}\n              y={11}\n              textAnchor=\"middle\"\n              fontSize={8}\n              fontWeight={700}\n              letterSpacing={1.6}\n              fill={FABRIC}\n            >\n              UI LAB\n            </text>\n          </g>\n          <path ref={tapeL} d={tapePath(P_MIN, -1)} fill={TAPE_FILL} />\n          <path ref={tapeR} d={tapePath(P_MIN, 1)} fill={TAPE_FILL} />\n          {Array.from({ length: TOOTH_COUNT }, (_, i) =>\n            ([-1, 1] as const).map((side) => (\n              <rect\n                key={`${i}${side}`}\n                ref={(el) => {\n                  teeth.current[i * 2 + (side === 1 ? 1 : 0)] = el;\n                }}\n                // Each tooth reaches 1.5 past the seam, into the gap\n                // between two teeth on the other side: that overlap is\n                // what locks a zip.\n                x={side === -1 ? -6.5 : -1.5}\n                y={-2}\n                width={8}\n                height={4}\n                rx={1.2}\n                transform={toothTransform(i, side, P_MIN)}\n                fill={`url(#${uid}b)`}\n              />\n            )),\n          )}\n          {/* The zip ends here. */}\n          <rect\n            x={CX - 4.5}\n            y={P_MAX + 2}\n            width={9}\n            height={7}\n            rx={1.5}\n            fill={BRASS_DARK}\n          />\n\n          <g ref={slider} transform={`translate(${CX} ${P_MIN})`}>\n            <g ref={tab}>\n              {/* A leather pull, riveted to the slider's bail. */}\n              <rect\n                x={-8}\n                y={2}\n                width={16}\n                height={42}\n                rx={5}\n                fill={LEATHER}\n              />\n              <rect\n                x={-5.5}\n                y={5}\n                width={11}\n                height={36}\n                rx={3.5}\n                fill=\"none\"\n                stroke={THREAD}\n                strokeWidth={0.8}\n                strokeDasharray=\"2 1.6\"\n                opacity={0.7}\n              />\n              <circle cy={34} r={2.6} fill={`url(#${uid}b)`} />\n            </g>\n            <path\n              d={`M-11 ${-SLIDER_TOP} H11 L8 8 Q0 11 -8 8 Z`}\n              strokeLinejoin=\"round\"\n              fill={`url(#${uid}b)`}\n              stroke={BRASS_DARK}\n              strokeWidth={0.8}\n            />\n            <circle r={2.6} fill={BRASS_DARK} />\n          </g>\n        </svg>\n\n        {/* A full-height column, so translating it by a percentage of its\n            own height moves the handle in the card's units without\n            measuring anything. */}\n        <div\n          ref={columnRef}\n          className=\"pointer-events-none absolute inset-0 z-10\"\n          style={{ transform: `translateY(${(P_MIN / H) * 100}%)` }}\n        >\n          {/* The hit target and keyboard handle, over the slider and tab. */}\n          <div\n            ref={handleRef}\n            role=\"slider\"\n            tabIndex={0}\n            aria-label=\"Zipper\"\n            aria-orientation=\"vertical\"\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={0}\n            aria-valuetext=\"Zipped\"\n            className=\"pointer-events-auto absolute left-1/2 w-12 -translate-x-1/2 cursor-grab touch-none rounded-full outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:cursor-grabbing\"\n            // From the slider's top edge to just past the tab's end.\n            style={{ top: `${(-12 / H) * 100}%`, height: `${(58 / H) * 100}%` }}\n            onPointerDown={(e) => {\n              if (e.button !== 0 || drag.current) return;\n              glide.current?.stop();\n              drag.current = {\n                id: e.pointerId,\n                startY: e.clientY,\n                startP: p.get(),\n                lastY: e.clientY,\n                lastT: e.timeStamp,\n                v: 0,\n              };\n              e.currentTarget.setPointerCapture(e.pointerId);\n            }}\n            onPointerMove={(e) => {\n              const d = drag.current;\n              if (!d || d.id !== e.pointerId) return;\n              const k = scale();\n              const dt = Math.max(e.timeStamp - d.lastT, 1) / 1000;\n              // Smoothed, so one uneven sample can't decide the glide.\n              d.v = d.v * 0.5 + (((e.clientY - d.lastY) * k) / dt) * 0.5;\n              d.lastY = e.clientY;\n              d.lastT = e.timeStamp;\n              // The zip's end stops are hard, like the metal ones.\n              p.set(clamp(d.startP + (e.clientY - d.startY) * k, P_MIN, P_MAX));\n              if (reduceMotion) return;\n              const box = boxRef.current!.getBoundingClientRect();\n              const px = (e.clientX - box.left) * k - CX;\n              const py = (e.clientY - box.top) * k - p.get();\n              lean.set(\n                clamp(\n                  (-Math.atan2(px, Math.max(py, 20)) * 180) / Math.PI,\n                  -35,\n                  35,\n                ),\n              );\n            }}\n            onPointerUp={(e) => {\n              const d = drag.current;\n              if (!d || d.id !== e.pointerId) return;\n              drag.current = null;\n              lean.set(0);\n              if (reduceMotion) return;\n              // A zip has friction: a flick carries it on only a little,\n              // and near either end it runs home.\n              let target = clamp(p.get() + d.v * 0.06, P_MIN, P_MAX);\n              if (target < P_MIN + 12) target = P_MIN;\n              if (target > P_MAX - 12) target = P_MAX;\n              glide.current = animate(p, target, { ...SETTLE, velocity: d.v });\n            }}\n            onPointerCancel={(e) => {\n              if (drag.current?.id !== e.pointerId) return;\n              drag.current = null;\n              lean.set(0);\n            }}\n            onKeyDown={(e) => {\n              const range = P_MAX - P_MIN;\n              const current = p.get();\n              const target = {\n                ArrowDown: current + range * STEP,\n                ArrowRight: current + range * STEP,\n                ArrowUp: current - range * STEP,\n                ArrowLeft: current - range * STEP,\n                PageDown: current + range * STEP * 2.5,\n                PageUp: current - range * STEP * 2.5,\n                Home: P_MIN,\n                End: P_MAX,\n              }[e.key];\n              if (target === undefined) return;\n              e.preventDefault();\n              moveTo(target);\n            }}\n          />\n        </div>\n      </div>\n\n      <div className=\"mt-3 flex items-center justify-between px-1 text-sm\">\n        <span\n          ref={readoutRef}\n          aria-hidden\n          className=\"font-medium text-foreground tabular-nums\"\n        >\n          Zipped\n        </span>\n        <span className=\"text-muted-foreground\">Drag the pull down</span>\n      </div>\n    </div>\n  );\n}\n\nexport default function ZipperRevealDemo() {\n  return <ZipperReveal />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/zipper-reveal"}