{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stamp-card",
  "title": "Stamp Card",
  "description": "Collectible postage stamp with perforated edges, 3D tilt and glare, an inked postmark animation and a fanned StampSheet.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/new-york/stamp-card/stamp-card.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useAnimate,\n  useMotionTemplate,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type Transition,\n} from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\nexport type StampCardVariant = \"classic\" | \"airmail\" | \"minimal\"\n\nexport interface StampCardProps {\n  /** Image URL for the artwork. Missing or broken images fall back to a duotone illustration. */\n  image?: string\n  /** Title printed under the artwork. Default: \"\" */\n  title?: string\n  /** Denomination shown in the top-right corner, e.g. \"₹25\" or \"50c\". Default: \"₹25\" */\n  value?: string\n  /** Country or brand printed in the footer and around the postmark. Default: \"India\" */\n  country?: string\n  /** Small caption next to the country. Default: \"\" */\n  caption?: string\n  /** Visual style. Default: \"classic\" */\n  variant?: StampCardVariant\n  /** Paper color (any CSS color). Defaults to a cream, off-white or accent tint depending on the variant. */\n  paper?: string\n  /** Postmark ink color (any CSS color). Default: \"#0a0a0a\" */\n  ink?: string\n  /** Accent color for frames, text and the fallback artwork (any CSS color). Default: \"#ff4d12\" */\n  accent?: string\n  /** Perforation hole diameter in px. Default: 8 */\n  perforation?: number\n  /** Stamp width in px. The height follows at roughly 5:6, snapped to the perforation grid. Default: 180 */\n  size?: number\n  /** Controlled postmark state. */\n  postmarked?: boolean\n  /** Initial postmark state when uncontrolled. Default: false */\n  defaultPostmarked?: boolean\n  /** Called with the new state when the stamp is clicked. */\n  onPostmark?: (postmarked: boolean) => void\n  /** Toggle the postmark on click / Enter. Default: true */\n  clickToPostmark?: boolean\n  /** Text in the middle of the postmark. Default: \"PAID\" */\n  postmarkLabel?: string\n  /** Maximum 3D tilt in degrees on hover. 0 disables. Default: 12 */\n  tilt?: number\n  /** Show a light glare that follows the pointer. Default: true */\n  glare?: boolean\n  /** Resting rotation in degrees. Default: 0 */\n  rotate?: number\n  className?: string\n}\n\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const\nconst EASE_IN_OUT = [0.76, 0, 0.24, 1] as const\nconst SPRING = { stiffness: 420, damping: 32 }\n\nconst NOISE = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")`\n\nconst PAPER: Record<StampCardVariant, string> = {\n  classic: \"#f3ead6\",\n  airmail: \"#fbf9f4\",\n  minimal: \"\",\n}\n\nconst hash = (s: string) => {\n  let h = 7\n  for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0\n  return Math.abs(h)\n}\n\n/** Snaps the stamp to a whole number of perforation steps so every edge ends on a hole. */\nexport function stampDims(size: number, perforation: number) {\n  const hole = Math.max(perforation, 2)\n  const step = hole * 1.75\n  const cols = Math.max(6, Math.round(size / step))\n  const width = cols * step\n  const rows = Math.max(7, Math.round((width * 1.2) / step))\n  return { width, height: rows * step, hole, step }\n}\n\nfunction perforationMask(width: number, height: number, hole: number, step: number) {\n  const r = hole / 2\n  // Alpha mask that punches the perforated edge, not a color gradient\n  return `radial-gradient(circle at center, transparent ${r}px, #000 ${r + 0.5}px) ${-step / 2}px ${-step / 2}px / ${step}px ${step}px repeat, linear-gradient(#000 0 0) center / ${width - hole}px ${height - hole}px no-repeat`\n}\n\nfunction Artwork({ accent, paper, seed }: { accent: string; paper: string; seed: number }) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\")\n  const deep = `color-mix(in oklab, ${accent} 62%, #000)`\n  const light = `color-mix(in oklab, ${accent} 22%, ${paper})`\n  const motif = seed % 3\n  const sunX = 30 + (seed % 40)\n\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 100 120\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 size-full\">\n      <defs>\n        <pattern id={`${uid}h`} width=\"3\" height=\"3\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(-35)\">\n          <line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"3\" strokeWidth=\"0.8\" style={{ stroke: deep }} />\n        </pattern>\n      </defs>\n      <rect width=\"100\" height=\"120\" style={{ fill: light }} />\n      {motif === 0 && (\n        <>\n          <circle cx={sunX} cy=\"42\" r=\"17\" style={{ fill: paper }} opacity=\"0.9\" />\n          <path d=\"M0 82 Q22 60 48 76 T100 66 V120 H0Z\" style={{ fill: accent }} />\n          <path d=\"M0 82 Q22 60 48 76 T100 66 V120 H0Z\" fill={`url(#${uid}h)`} opacity=\"0.25\" />\n          <path d=\"M0 100 Q30 82 62 96 T100 92 V120 H0Z\" style={{ fill: deep }} />\n        </>\n      )}\n      {motif === 1 && (\n        <>\n          {[40, 31, 22, 13].map((r, i) => (\n            <circle key={r} cx=\"50\" cy=\"58\" r={r} style={{ fill: i % 2 ? paper : accent }} opacity={i % 2 ? 0.85 : 1 - i * 0.12} />\n          ))}\n          <path d=\"M0 96 q10 -6 20 0 t20 0 t20 0 t20 0 t20 0 V120 H0Z\" style={{ fill: deep }} />\n          <path d=\"M0 104 q10 -6 20 0 t20 0 t20 0 t20 0 t20 0\" fill=\"none\" strokeWidth=\"1.5\" style={{ stroke: paper }} opacity=\"0.6\" />\n        </>\n      )}\n      {motif === 2 && (\n        <>\n          <circle cx={100 - sunX} cy=\"34\" r=\"11\" style={{ fill: accent }} />\n          <path d=\"M-5 104 L32 46 L54 78 L70 56 L105 104Z\" style={{ fill: deep }} />\n          <path d=\"M-5 104 L32 46 L54 78 L70 56 L105 104Z\" fill={`url(#${uid}h)`} opacity=\"0.3\" />\n          <path d=\"M32 46 L24 58 L30 56 L36 62 L40 58Z\" style={{ fill: paper }} opacity=\"0.9\" />\n          <rect y=\"104\" width=\"100\" height=\"16\" style={{ fill: accent }} />\n        </>\n      )}\n    </svg>\n  )\n}\n\nfunction Art({ image, alt, accent, paper, seed, duotone }: { image?: string; alt: string; accent: string; paper: string; seed: number; duotone: boolean }) {\n  const [state, setState] = useState<\"loading\" | \"loaded\" | \"error\">(\"loading\")\n  const loaded = state === \"loaded\"\n  return (\n    <>\n      <Artwork accent={accent} paper={paper} seed={seed} />\n      {image && state !== \"error\" && (\n        <img\n          src={image}\n          alt={alt}\n          draggable={false}\n          ref={(el) => {\n            if (el?.complete) setState(el.naturalWidth > 0 ? \"loaded\" : \"error\")\n          }}\n          onLoad={() => setState(\"loaded\")}\n          onError={() => setState(\"error\")}\n          className=\"absolute inset-0 size-full select-none object-cover transition-opacity duration-500\"\n          style={{ opacity: loaded ? 1 : 0, filter: duotone ? \"grayscale(1) contrast(1.15)\" : \"saturate(0.9) contrast(1.05)\" }}\n        />\n      )}\n      {image && loaded && duotone && (\n        <>\n          <span aria-hidden=\"true\" className=\"absolute inset-0 mix-blend-lighten\" style={{ backgroundColor: `color-mix(in oklab, ${accent} 60%, #000)` }} />\n          <span aria-hidden=\"true\" className=\"absolute inset-0 mix-blend-multiply\" style={{ backgroundColor: `color-mix(in oklab, ${accent} 25%, ${paper})` }} />\n        </>\n      )}\n    </>\n  )\n}\n\nfunction Postmark({ ink, text, label }: { ink: string; text: string; label: string }) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\")\n  const ring = `${text} • ${text} • `.toUpperCase()\n  return (\n    <svg viewBox=\"0 0 230 120\" className=\"size-full overflow-visible\" aria-hidden=\"true\">\n      <defs>\n        <filter id={`${uid}f`} x=\"-10%\" y=\"-10%\" width=\"120%\" height=\"120%\">\n          <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.8\" numOctaves=\"2\" seed=\"4\" result=\"n\" />\n          <feDisplacementMap in=\"SourceGraphic\" in2=\"n\" scale=\"2.6\" result=\"d\" />\n          <feTurbulence type=\"fractalNoise\" baseFrequency=\"0.35\" numOctaves=\"1\" seed=\"9\" result=\"m\" />\n          <feColorMatrix in=\"m\" type=\"matrix\" values=\"0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 -2.2 1.9\" result=\"mask\" />\n          <feComposite in=\"d\" in2=\"mask\" operator=\"in\" />\n        </filter>\n        <path id={`${uid}p`} d=\"M60 60 m-37 0 a37 37 0 1 1 74 0 a37 37 0 1 1 -74 0\" />\n      </defs>\n      <g filter={`url(#${uid}f)`} fill=\"none\" style={{ stroke: ink }}>\n        <circle cx=\"60\" cy=\"60\" r=\"47\" strokeWidth=\"2.6\" />\n        <circle cx=\"60\" cy=\"60\" r=\"28\" strokeWidth=\"1.6\" />\n        <text fontSize=\"9.5\" fontWeight=\"700\" letterSpacing=\"1.6\" stroke=\"none\" style={{ fill: ink, fontFamily: \"ui-monospace, monospace\" }}>\n          <textPath href={`#${uid}p`} textLength=\"228\" lengthAdjust=\"spacingAndGlyphs\">\n            {ring}\n          </textPath>\n        </text>\n        <text x=\"60\" y=\"64\" textAnchor=\"middle\" fontSize=\"13\" fontWeight=\"800\" letterSpacing=\"1.5\" stroke=\"none\" style={{ fill: ink, fontFamily: \"ui-monospace, monospace\" }}>\n          {label}\n        </text>\n        {[34, 47, 60, 73, 86].map((y) => (\n          <path key={y} d={`M112 ${y} q9 -7 18 0 t18 0 t18 0 t18 0 t18 0 t18 0`} strokeWidth=\"2.6\" strokeLinecap=\"round\" />\n        ))}\n      </g>\n    </svg>\n  )\n}\n\nexport function StampCard({\n  image,\n  title = \"\",\n  value = \"₹25\",\n  country = \"India\",\n  caption = \"\",\n  variant = \"classic\",\n  paper: paperProp,\n  ink = \"#0a0a0a\",\n  accent = \"#ff4d12\",\n  perforation = 8,\n  size = 180,\n  postmarked: postmarkedProp,\n  defaultPostmarked = false,\n  onPostmark,\n  clickToPostmark = true,\n  postmarkLabel = \"PAID\",\n  tilt = 12,\n  glare = true,\n  rotate = 0,\n  className,\n}: StampCardProps) {\n  const reduced = useReducedMotion()\n  const [inner, setInner] = useState(defaultPostmarked)\n  const postmarked = postmarkedProp ?? inner\n\n  const paper = paperProp || PAPER[variant] || `color-mix(in oklab, ${accent} 14%, #fff)`\n  const { width, height, hole, step } = stampDims(size, perforation)\n  const mask = perforationMask(width, height, hole, step)\n  const seed = hash(`${title}${country}${value}`)\n  const inset = hole / 2 + (variant === \"airmail\" ? 10 : 6)\n  const textColor = variant === \"minimal\" ? `color-mix(in oklab, ${accent} 72%, #000)` : `color-mix(in oklab, ${accent} 28%, #1c1917)`\n\n  // Pointer tilt\n  const tiltRef = useRef<HTMLDivElement>(null)\n  const px = useMotionValue(0.5)\n  const py = useMotionValue(0.5)\n  const hover = useMotionValue(0)\n  const active = tilt > 0 && !reduced\n  const rotateX = useSpring(useTransform(py, [0, 1], [tilt, -tilt]), SPRING)\n  const rotateY = useSpring(useTransform(px, [0, 1], [-tilt, tilt]), SPRING)\n  const lift = useSpring(hover, SPRING)\n  const y = useTransform(lift, [0, 1], [0, -6])\n  const shadowY = useTransform(lift, [0, 1], [3, 16])\n  const shadowBlur = useTransform(lift, [0, 1], [5, 22])\n  const shadowAlpha = useTransform(lift, [0, 1], [0.18, 0.28])\n  const shadow = useMotionTemplate`drop-shadow(0 ${shadowY}px ${shadowBlur}px rgba(0,0,0,${shadowAlpha}))`\n  // Solid sheen band (a third of the width) that tracks the pointer horizontally\n  const glareX = useTransform(px, (v) => `${v * 333 - 50}%`)\n\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    if (reduced || !tiltRef.current) return\n    const rect = tiltRef.current.getBoundingClientRect()\n    px.set((e.clientX - rect.left) / rect.width)\n    py.set((e.clientY - rect.top) / rect.height)\n    hover.set(1)\n  }\n\n  const reset = () => {\n    px.set(0.5)\n    py.set(0.5)\n    hover.set(0)\n  }\n\n  // Press the paper down when the postmark lands\n  const [scope, animateScope] = useAnimate<HTMLDivElement>()\n  const prevRef = useRef(postmarked)\n  useEffect(() => {\n    if (postmarked && !prevRef.current && !reduced && scope.current) {\n      animateScope(scope.current, { scale: [1, 0.955, 1.01, 1] }, { duration: 0.45, ease: EASE_IN_OUT, times: [0, 0.3, 0.7, 1] })\n    }\n    prevRef.current = postmarked\n  }, [postmarked, reduced, animateScope, scope])\n\n  const toggle = () => {\n    if (!clickToPostmark) return\n    const next = !postmarked\n    if (postmarkedProp === undefined) setInner(next)\n    onPostmark?.(next)\n  }\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault()\n      toggle()\n    }\n  }\n\n  const stampT: Transition = reduced\n    ? { duration: 0 }\n    : {\n        default: { duration: 0.34, ease: EASE_OUT },\n        opacity: { duration: 0.9, times: [0, 0.3, 1], ease: \"easeOut\" },\n      }\n\n  const panel: CSSProperties =\n    variant === \"classic\"\n      ? { border: `1px solid color-mix(in oklab, ${accent} 45%, ${paper})`, padding: 5 }\n      : variant === \"airmail\"\n        ? { backgroundColor: paper, padding: 6, boxShadow: `inset 0 0 0 1px color-mix(in oklab, #1c1917 12%, transparent)` }\n        : { padding: 0 }\n\n  const paperBg =\n    variant === \"airmail\"\n      ? // Hard-edged airmail stripes (a pattern, no color blending)\n        `repeating-linear-gradient(-45deg, #d7263d 0 9px, transparent 9px 14px, #1f4fbf 14px 23px, transparent 23px 28px), ${paper}`\n      : paper\n\n  return (\n    <div className={cn(\"relative inline-block shrink-0\", className)} style={{ perspective: 900, rotate: `${rotate}deg`, width, height }}>\n      <motion.div\n        ref={tiltRef}\n        onPointerMove={onPointerMove}\n        onPointerLeave={reset}\n        onClick={toggle}\n        onKeyDown={clickToPostmark ? onKeyDown : undefined}\n        role={clickToPostmark ? \"button\" : undefined}\n        tabIndex={clickToPostmark ? 0 : undefined}\n        aria-pressed={clickToPostmark ? postmarked : undefined}\n        aria-label={`${title || country} stamp, ${value}${postmarked ? \", postmarked\" : \"\"}`}\n        className={cn(\"relative size-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4\", clickToPostmark && \"cursor-pointer\")}\n        style={{ rotateX: active ? rotateX : 0, rotateY: active ? rotateY : 0, y: reduced ? 0 : y, transformStyle: \"preserve-3d\" }}\n      >\n        <div ref={scope} className=\"size-full\">\n          <motion.div className=\"size-full\" style={{ filter: shadow }}>\n            <div\n              className=\"@container relative size-full select-none overflow-hidden\"\n              style={{ WebkitMask: mask, mask, background: paperBg } as CSSProperties}\n            >\n              <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 opacity-[0.1] mix-blend-multiply\" style={{ backgroundImage: NOISE }} />\n\n              <div className=\"absolute flex flex-col\" style={{ inset, ...panel }}>\n                <div className=\"relative flex-1 overflow-hidden\" style={{ backgroundColor: paper }}>\n                  <Art image={image} alt={title} accent={accent} paper={paper} seed={seed} duotone={variant === \"minimal\"} />\n                  <span\n                    className=\"absolute right-0 top-0 pb-[1.5cqw] pl-[2.5cqw] pr-[2cqw] pt-[1cqw] font-serif text-[11cqw] font-bold leading-none tracking-tight\"\n                    style={{ backgroundColor: paper, color: textColor, borderBottomLeftRadius: variant === \"minimal\" ? 6 : 0 }}\n                  >\n                    {value}\n                  </span>\n                </div>\n                <div className=\"pt-[3cqw]\" style={{ color: textColor }}>\n                  {title && <p className=\"truncate font-serif text-[8cqw] font-semibold italic leading-tight\">{title}</p>}\n                  <div className=\"mt-[1cqw] flex items-baseline justify-between gap-2 leading-none\">\n                    <span className=\"truncate text-[5.2cqw] font-bold uppercase tracking-[0.18em]\">{country}</span>\n                    {caption && <span className=\"shrink-0 text-[4.4cqw] uppercase tracking-wider opacity-70\">{caption}</span>}\n                  </div>\n                </div>\n              </div>\n\n              {glare && !reduced && (\n                <motion.div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 overflow-hidden mix-blend-soft-light\" style={{ opacity: lift }}>\n                  <motion.div className=\"absolute -inset-y-1/4 left-0 w-1/3 bg-white/55\" style={{ x: glareX, skewX: -20 }} />\n                </motion.div>\n              )}\n            </div>\n          </motion.div>\n        </div>\n\n        <AnimatePresence initial={false}>\n          {postmarked && (\n            <motion.div\n              key=\"postmark\"\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute mix-blend-multiply\"\n              style={{ left: \"34%\", top: \"50%\", width: width * 1.05, height: width * 0.55, transformOrigin: \"26% 50%\" }}\n              initial={{ scale: 1.9, rotate: -30, opacity: 0, filter: \"blur(3px)\" }}\n              animate={{ scale: 1, rotate: -14, opacity: [0, 0.95, 0.8], filter: \"blur(0px)\" }}\n              exit={{ scale: 1.08, opacity: 0, filter: \"blur(2px)\", transition: { duration: reduced ? 0 : 0.3, ease: EASE_OUT } }}\n              transition={stampT}\n            >\n              <Postmark ink={ink} text={country || \"Post\"} label={postmarkLabel} />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  )\n}\n\nexport interface StampSheetProps {\n  /** Stamps in the fan, left to right. Each accepts every StampCard prop. */\n  stamps: StampCardProps[]\n  /** Stamp width in px, applied unless a stamp sets its own `size`. Default: 150 */\n  size?: number\n  /** Distance between stamp centers when spread on hover, px. Default: size * 0.8 */\n  spread?: number\n  /** Rotation between neighbouring stamps at rest, degrees. Default: 7 */\n  fan?: number\n  className?: string\n}\n\n/** A fanned stack of stamps that spreads out on hover. */\nexport function StampSheet({ stamps, size = 150, spread, fan = 7, className }: StampSheetProps) {\n  const reduced = useReducedMotion()\n  const [open, setOpen] = useState(false)\n  const gap = spread ?? size * 0.8\n  const { width, height } = stampDims(size, 8)\n  const center = (stamps.length - 1) / 2\n  const transition: Transition = reduced ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 34 }\n\n  return (\n    <div\n      className={cn(\"relative\", className)}\n      style={{ width: width + Math.max(stamps.length - 1, 0) * gap, height: height + 48 }}\n      onPointerEnter={() => setOpen(true)}\n      onPointerLeave={() => setOpen(false)}\n      onFocus={() => setOpen(true)}\n      onBlur={() => setOpen(false)}\n    >\n      {stamps.map((stamp, i) => {\n        const o = i - center\n        return (\n          <motion.div\n            key={i}\n            className=\"absolute left-1/2 top-1/2\"\n            style={{ marginLeft: -width / 2, marginTop: -height / 2, zIndex: i }}\n            initial={false}\n            animate={{\n              x: open ? o * gap : o * size * 0.26,\n              y: open ? Math.abs(o) * 4 : Math.abs(o) * 8,\n              rotate: open ? o * fan * 0.35 : o * fan,\n            }}\n            transition={{ ...transition, delay: reduced ? 0 : Math.abs(o) * 0.02 }}\n          >\n            <StampCard tilt={8} size={size} {...stamp} />\n          </motion.div>\n        )\n      })}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/stamp-card",
  "type": "registry:component"
}