{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jpg-card-holder",
  "title": "JPG Card Holder",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Leather card holder shaped like a JPG file, with cards you can pull out.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "src/registry/components/jpg-card-holder/jpg-card-holder.tsx",
      "content": "\"use client\"\n\nimport { useId, useRef, useState } from \"react\"\nimport type { ComponentProps, PointerEvent } from \"react\"\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// 320x424 pocket with a 120px dog-ear cut from the top-right corner.\nconst POCKET_PATH =\n  \"M12 0H196.69A8 8 0 0 1 202.34 2.34L317.66 117.66A8 8 0 0 1 320 123.31V408A16 16 0 0 1 304 424H16A16 16 0 0 1 0 408V12A12 12 0 0 1 12 0Z\"\n\n// The dog-ear folded down onto the pocket, mirroring the cut.\nconst FLAP_PATH =\n  \"M0 4.83V104A16 16 0 0 0 16 120H115.17A2 2 0 0 0 116.59 116.59L3.41 3.41A2 2 0 0 0 0 4.83Z\"\n\n// Open along the top and the upper right side, where the flap sits.\nconst STITCH_PATH = \"M10 8V414H310V328\"\n\n// 150x120 image icon; even-odd cuts the sky out of the frame and mountains.\nconst IMAGE_ICON_PATH =\n  \"M15.5 0H134.5A15.5 15.5 0 0 1 150 15.5V104.5A15.5 15.5 0 0 1 134.5 120H15.5A15.5 15.5 0 0 1 0 104.5V15.5A15.5 15.5 0 0 1 15.5 0Z\" +\n  \"M7 95V15.5A8.5 8.5 0 0 1 15.5 7H134.5A8.5 8.5 0 0 1 143 15.5V98L101.03 57.37Q96 52.5 90.45 56.76L59.34 80.63Q53 85.5 46.27 81.17L37.05 75.25Q32 72 27.58 76.06Z\"\n\n/** Card width the pull distances below are measured against (px). */\nconst CARD_WIDTH = 240\n\n/** How far a card follows the pointer 1:1 before it starts to resist (px). */\nconst MAX_PULL = 220\n\ntype DragState = {\n  card: number\n  pointerId: number\n  startX: number\n  startY: number\n  startOffset: number\n  angle: number\n  scale: number\n}\n\n// Overshoot shrinks as it grows and never exceeds `dimension`.\nfunction rubberBand(overshoot: number, dimension: number) {\n  return (overshoot * dimension * 0.55) / (dimension + 0.55 * overshoot)\n}\n\n// Tracking stays 1:1; only the limits follow the rendered size.\nfunction resistPull(offset: number, scale: number) {\n  const maxPull = MAX_PULL * scale\n\n  if (offset < -maxPull) {\n    return -maxPull - rubberBand(-maxPull - offset, 80 * scale)\n  }\n\n  // The pocket has a bottom, so pushing a card in barely moves it.\n  if (offset > 0) {\n    return rubberBand(offset, 24 * scale)\n  }\n\n  return offset\n}\n\nexport function JpgCardHolder({\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  onPointerDown,\n  onClick,\n  ...props\n}: ComponentProps<\"div\">) {\n  const grainId = useId()\n  const shouldReduceMotion = useReducedMotion()\n\n  // Each card's travel along its own tilt: negative is out of the pocket.\n  const backOffset = useMotionValue(0)\n  const frontOffset = useMotionValue(0)\n  const offsets = [backOffset, frontOffset]\n\n  const [isPeeking, setIsPeeking] = useState(false)\n  const [draggingCard, setDraggingCard] = useState<number | null>(null)\n\n  const dragRef = useRef<DragState | null>(null)\n  const pointerTypeRef = useRef(\"\")\n\n  function handleCardPointerDown(\n    card: number,\n    event: PointerEvent<HTMLDivElement>\n  ) {\n    // Touch scrolls the page instead; a tap on the root peeks.\n    if (event.pointerType === \"touch\" || event.button !== 0) return\n\n    event.currentTarget.setPointerCapture(event.pointerId)\n    // Catches a card that is still springing back.\n    offsets[card].stop()\n\n    dragRef.current = {\n      card,\n      pointerId: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      startOffset: offsets[card].get(),\n      // The tilt lives in the card's `rotate-*` class.\n      angle:\n        (parseFloat(getComputedStyle(event.currentTarget).rotate) || 0) *\n        (Math.PI / 180),\n      // Layout width, so the rotation doesn't inflate it.\n      scale: event.currentTarget.offsetWidth / CARD_WIDTH,\n    }\n    setDraggingCard(card)\n  }\n\n  function handleCardPointerMove(event: PointerEvent<HTMLDivElement>) {\n    const drag = dragRef.current\n    if (!drag || drag.pointerId !== event.pointerId) return\n\n    // Project the pointer's travel onto the card's tilted axis.\n    const along =\n      (event.clientY - drag.startY) * Math.cos(drag.angle) -\n      (event.clientX - drag.startX) * Math.sin(drag.angle)\n\n    offsets[drag.card].set(resistPull(drag.startOffset + along, drag.scale))\n  }\n\n  function handleCardPointerUp(event: PointerEvent<HTMLDivElement>) {\n    const drag = dragRef.current\n    if (!drag || drag.pointerId !== event.pointerId) return\n\n    dragRef.current = null\n    setDraggingCard(null)\n\n    animate(\n      offsets[drag.card],\n      0,\n      shouldReduceMotion\n        ? { duration: 0.15, ease: \"easeOut\" }\n        : { type: \"spring\", visualDuration: 0.35, bounce: 0.1 }\n    )\n  }\n\n  function getCardProps(card: number) {\n    return {\n      \"data-dragging\": draggingCard === card || undefined,\n      style: { y: offsets[card] },\n      onPointerDown: (event: PointerEvent<HTMLDivElement>) =>\n        handleCardPointerDown(card, event),\n      onPointerMove: handleCardPointerMove,\n      onPointerUp: handleCardPointerUp,\n      onPointerCancel: handleCardPointerUp,\n    }\n  }\n\n  const foil = (\n    <>\n      <g transform=\"translate(83 142) scale(1.025)\">\n        <path d={IMAGE_ICON_PATH} fillRule=\"evenodd\" />\n        <circle cx={44} cy={45} r={16.5} />\n      </g>\n      <text\n        x={160}\n        y={381}\n        fontSize={62}\n        textAnchor=\"middle\"\n        className=\"font-sans font-medium\"\n      >\n        JPG\n      </text>\n    </>\n  )\n\n  return (\n    <div\n      data-slot=\"jpg-card-holder\"\n      data-peek={isPeeking || undefined}\n      className={cn(\n        \"group/jpg-card-holder @container relative aspect-320/424 w-80 select-none\",\n        className\n      )}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event)\n        if (event.pointerType !== \"touch\") setIsPeeking(true)\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event)\n        // Keep peeking while a dragged card is outside the root.\n        if (event.pointerType !== \"touch\" && !dragRef.current) {\n          setIsPeeking(false)\n        }\n      }}\n      onPointerDown={(event) => {\n        onPointerDown?.(event)\n        pointerTypeRef.current = event.pointerType\n      }}\n      onClick={(event) => {\n        onClick?.(event)\n        if (pointerTypeRef.current === \"touch\") setIsPeeking((value) => !value)\n      }}\n      {...props}\n    >\n      {/* Every length inside is a spacing step, so this scales with the root.\n          Card radius is `cqw`: lint folds a spacing radius into `rounded-xl`. */}\n      <div className=\"absolute inset-0 drop-shadow-[0_--spacing(2.25)_--spacing(1.75)_rgb(0_0_0/0.1)] [--spacing:calc(100cqw/80)] perspective-distant\">\n        {/* The outer box drags; the inner card peeks, so the hit area stays put. */}\n        <motion.div\n          aria-hidden\n          className=\"absolute -top-18 left-20 h-95 w-60 origin-top-left rotate-6 cursor-grab data-dragging:cursor-grabbing\"\n          {...getCardProps(0)}\n        >\n          <div className=\"size-full origin-bottom rounded-[3.75cqw] bg-muted-foreground transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-peek/jpg-card-holder:duration-400 motion-safe:group-data-peek/jpg-card-holder:-translate-y-4 motion-safe:group-data-peek/jpg-card-holder:-rotate-3 motion-safe:group-data-peek/jpg-card-holder:delay-40\" />\n        </motion.div>\n\n        <motion.div\n          aria-hidden\n          className=\"absolute -top-13.5 left-30 h-95 w-60 origin-top-left rotate-14 cursor-grab data-dragging:cursor-grabbing\"\n          {...getCardProps(1)}\n        >\n          <div className=\"size-full origin-bottom rounded-[3.75cqw] bg-foreground transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-peek/jpg-card-holder:duration-400 motion-safe:group-data-peek/jpg-card-holder:-translate-y-7 motion-safe:group-data-peek/jpg-card-holder:rotate-3\" />\n        </motion.div>\n\n        <svg\n          aria-hidden\n          viewBox=\"0 0 320 424\"\n          className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n        >\n          <defs>\n            {/* Lit noise becomes alpha, so the grain takes the fill's color. */}\n            <filter id={grainId} x=\"0\" y=\"0\" width=\"100%\" height=\"100%\">\n              <feTurbulence\n                type=\"fractalNoise\"\n                baseFrequency=\"0.7\"\n                numOctaves=\"3\"\n                seed=\"7\"\n              />\n              <feDiffuseLighting surfaceScale=\"0.6\" lightingColor=\"#fff\">\n                <feDistantLight azimuth=\"235\" elevation=\"55\" />\n              </feDiffuseLighting>\n              <feColorMatrix\n                type=\"matrix\"\n                values=\"0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  -1 0 0 0 1\"\n              />\n              <feComposite in=\"SourceGraphic\" operator=\"in\" />\n            </filter>\n          </defs>\n\n          {/* Only the pocket shape blocks the cards, not the SVG's cut corner. */}\n          <path\n            d={POCKET_PATH}\n            className=\"pointer-events-auto fill-card stroke-foreground/10\"\n          />\n          <path\n            d={POCKET_PATH}\n            filter={`url(#${grainId})`}\n            className=\"fill-foreground opacity-15\"\n          />\n\n          <path\n            d={STITCH_PATH}\n            fill=\"none\"\n            strokeWidth={1.5}\n            className=\"stroke-foreground/10\"\n          />\n          <path\n            d={STITCH_PATH}\n            fill=\"none\"\n            strokeWidth={1.5}\n            strokeDasharray=\"2.5 10.5\"\n            strokeLinecap=\"round\"\n            className=\"stroke-foreground/40\"\n          />\n\n          {/* The foil is smooth, so an opaque base hides the grain beneath it. */}\n          <g className=\"fill-card\">{foil}</g>\n          <g\n            strokeWidth={0.75}\n            className=\"fill-foreground/15 stroke-foreground/10\"\n          >\n            {foil}\n          </g>\n        </svg>\n\n        {/* Static shadows crossfade, so the lift animates only opacity. */}\n        <svg\n          aria-hidden\n          viewBox=\"0 0 120 120\"\n          className=\"pointer-events-none absolute top-0 right-0 size-30 -translate-x-px translate-y-px overflow-visible blur-[--spacing(0.25)] transition-opacity duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-peek/jpg-card-holder:duration-400 motion-safe:group-data-peek/jpg-card-holder:opacity-0\"\n        >\n          <path d={FLAP_PATH} fill=\"rgb(0 0 0 / 0.12)\" />\n        </svg>\n        <svg\n          aria-hidden\n          viewBox=\"0 0 120 120\"\n          className=\"pointer-events-none absolute top-0 right-0 size-30 -translate-x-0.75 translate-y-1 overflow-visible opacity-0 blur-[--spacing(0.75)] transition-opacity duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-peek/jpg-card-holder:duration-400 motion-safe:group-data-peek/jpg-card-holder:opacity-100\"\n        >\n          <path d={FLAP_PATH} fill=\"rgb(0 0 0 / 0.16)\" />\n        </svg>\n\n        {/* Rotates about the crease: the 1 1 0 axis from the top-left corner. */}\n        <svg\n          aria-hidden\n          viewBox=\"0 0 120 120\"\n          className=\"pointer-events-none absolute top-0 right-0 size-30 origin-top-left overflow-visible transition-[rotate] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-peek/jpg-card-holder:duration-400 motion-safe:group-data-peek/jpg-card-holder:rotate-[1_1_0_25deg]\"\n        >\n          <path\n            d={FLAP_PATH}\n            className=\"pointer-events-auto fill-card stroke-foreground/10\"\n          />\n        </svg>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/jpg-card-holder.tsx"
    }
  ],
  "docs": "https://chanhdai.com/components/jpg-card-holder",
  "categories": [
    "illustrations"
  ],
  "type": "registry:component"
}