{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rubber-stamp","type":"registry:component","title":"Rubber stamp","description":"Stamps a document approved or rejected, with ink that looks pressed in.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/rubber-stamp.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n  type ValueAnimationTransition,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Verdict = \"approved\" | \"rejected\";\n\ntype Impression = {\n  id: number;\n  verdict: Verdict;\n  rotate: number;\n  seed: number;\n  x: number;\n  y: number;\n};\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The press accelerates into the paper like a hand pushing down, so the\n// moment of contact lands hard instead of drifting in.\nconst EASE_PRESS = [0.55, 0, 1, 0.45] as const;\n// Hand stamps never land square; +-6deg reads as human without looking drunk.\nconst MAX_TILT = 6;\n// Nor dead centre: a few px of drift per press.\nconst MAX_DRIFT = 4;\n// Where the stamp hovers before it comes down, and where it lifts back to.\nconst HOVER_Y = -36;\nconst LIFT_Y = -48;\n\n// Small deterministic PRNG, so one seed gives one tilt, drift and ink grain.\nfunction mulberry32(seed: number) {\n  let a = seed;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction rollImpression(id: number, verdict: Verdict): Impression {\n  const seed = Math.floor(Math.random() * 10_000);\n  const rand = mulberry32(seed);\n  return {\n    id,\n    verdict,\n    seed,\n    rotate: (rand() * 2 - 1) * MAX_TILT,\n    x: (rand() * 2 - 1) * MAX_DRIFT,\n    y: (rand() * 2 - 1) * MAX_DRIFT,\n  };\n}\n\n// Stamp-pad ink is a physical material: the classic blue-violet office pad\n// for approvals, and the danger red (which is red ink anyway) for\n// rejections. The blue lifts in the dark theme to stay readable.\nconst INK: Record<Verdict, string> = {\n  approved: \"light-dark(oklch(0.45 0.16 268), oklch(0.7 0.13 268))\",\n  rejected: \"var(--destructive)\",\n};\n// The stamp's wooden handle and mount, and the brass ring between them.\nconst WOOD_LIGHT = \"oklch(0.74 0.085 68)\";\nconst WOOD = \"oklch(0.62 0.095 58)\";\nconst WOOD_DARK = \"oklch(0.5 0.09 50)\";\nconst BRASS = \"oklch(0.8 0.11 85)\";\n\nconst LABEL: Record<Verdict, string> = {\n  approved: \"Approved\",\n  rejected: \"Rejected\",\n};\n\nexport function RubberStamp({\n  children,\n  title,\n  onChange,\n  className,\n}: {\n  children: React.ReactNode;\n  // Names the document for screen readers, e.g. \"Expense request EXP-2041\".\n  title: string;\n  onChange?: (value: Verdict | null) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [impression, setImpression] = useState<Impression | null>(null);\n  const [ink, setInk] = useState<Verdict>(\"approved\");\n  const presses = useRef(0);\n  const run = useRef(0);\n  const running = useRef<AnimationPlaybackControls[]>([]);\n\n  const toolY = useMotionValue(LIFT_Y);\n  const toolOpacity = useMotionValue(0);\n  const toolRotate = useMotionValue(0);\n  const squashX = useMotionValue(1);\n  const squashY = useMotionValue(1);\n  const pageY = useMotionValue(0);\n  // 0 when the stamp is at its highest, 1 on the paper.\n  const near = useTransform(\n    () => toolOpacity.get() * (1 - Math.min(1, -toolY.get() / -LIFT_Y)),\n  );\n  const shadowOpacity = useTransform(near, (n) => n * 0.28);\n  const shadowScale = useTransform(near, (n) => 1.15 - n * 0.15);\n\n  useEffect(() => {\n    const list = running.current;\n    return () => list.forEach((c) => c.stop());\n  }, []);\n\n  const verdict = impression?.verdict ?? null;\n\n  const go = (\n    mv: MotionValue<number>,\n    to: number | number[],\n    transition: ValueAnimationTransition<number>,\n  ) => {\n    const c = animate(mv, to, transition);\n    running.current.push(c);\n    return c;\n  };\n\n  const stopAll = () => {\n    running.current.forEach((c) => c.stop());\n    running.current.length = 0;\n  };\n\n  // The whole press runs about 0.6s. It is a staged physical sequence (lift,\n  // press, squash, lift) where no single move is over 200ms, and pressing\n  // again at any point restarts it from wherever the stamp is.\n  const stamp = async (next: Verdict) => {\n    const id = ++run.current;\n    stopAll();\n    const mark = rollImpression(++presses.current, next);\n\n    if (reduceMotion) {\n      toolOpacity.jump(0);\n      setImpression(mark);\n      onChange?.(next);\n      return;\n    }\n\n    setInk(next);\n    toolRotate.jump(mark.rotate);\n    squashX.jump(1);\n    squashY.jump(1);\n    // A press mid-lift carries on from wherever the stamp is.\n    if (toolOpacity.get() === 0) toolY.jump(LIFT_Y);\n\n    // Lifted over the page.\n    await Promise.all([\n      go(toolY, HOVER_Y, { duration: 0.14, ease: EASE_OUT }),\n      go(toolOpacity, 1, { duration: 0.1, ease: EASE_OUT }),\n    ]);\n    if (id !== run.current) return;\n\n    // Down.\n    await go(toolY, 0, { duration: 0.09, ease: EASE_PRESS });\n    if (id !== run.current) return;\n\n    // Contact: the ink lands, the rubber squashes a touch and the page gives\n    // by 1.5px. Kept within 0.95 to 1.03 so it reads as weight, not cartoon.\n    setImpression(mark);\n    onChange?.(next);\n    navigator.vibrate?.(8);\n    go(squashY, [1, 0.95, 1], { duration: 0.16, ease: EASE_OUT });\n    go(squashX, [1, 1.03, 1], { duration: 0.16, ease: EASE_OUT });\n    go(pageY, [0, 1.5, 0], { duration: 0.18, ease: EASE_OUT });\n\n    // Held a beat on the paper, then lifted away. The fade trails the lift\n    // so you see it leave rather than vanish in place.\n    go(toolY, LIFT_Y, { duration: 0.2, delay: 0.08, ease: EASE_OUT });\n    go(toolOpacity, 0, { duration: 0.16, delay: 0.14, ease: EASE_OUT });\n  };\n\n  const undo = () => {\n    if (!impression) return;\n    run.current++;\n    stopAll();\n    go(toolOpacity, 0, { duration: 0.12, ease: EASE_OUT });\n    setImpression(null);\n    onChange?.(null);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;\n    const key = e.key.toLowerCase();\n    if (key === \"a\") stamp(\"approved\");\n    else if (key === \"r\") stamp(\"rejected\");\n    else if (key === \"u\") undo();\n    else return;\n    e.preventDefault();\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex w-[min(440px,100%)] flex-col items-center gap-6\",\n        className,\n      )}\n      onKeyDown={onKeyDown}\n    >\n      <motion.article\n        tabIndex={0}\n        aria-label={`${title}, ${verdict ? LABEL[verdict].toLowerCase() : \"pending\"}`}\n        aria-description=\"Press A to approve, R to reject, U to undo.\"\n        aria-keyshortcuts=\"A R U\"\n        style={{ y: pageY }}\n        className=\"relative w-full rounded-2xl bg-background p-6 shadow-raised outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground\"\n      >\n        {children}\n\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute right-6 bottom-5 h-14 w-[168px]\"\n        >\n          {/* The box a form leaves for the stamp. It stays under the ink,\n              the way a printed box does on paper. */}\n          <div className=\"absolute inset-0 flex items-center justify-center rounded-lg border border-dashed border-foreground/15\">\n            {/* Fades under a fresh impression so the two words never\n                compete; the box itself stays, like print on paper. */}\n            <span\n              className={cn(\n                \"text-[12px] font-medium tracking-[0.14em] text-muted-foreground/70 uppercase transition-[opacity] duration-200 ease-out\",\n                impression && \"opacity-0\",\n              )}\n            >\n              Office use\n            </span>\n          </div>\n\n          <AnimatePresence initial={false}>\n            {impression && (\n              <Ink\n                key={impression.id}\n                mark={impression}\n                reduce={!!reduceMotion}\n              />\n            )}\n          </AnimatePresence>\n\n          {/* The stamp's shadow on the page, tightening and darkening as it\n              comes down: the cue that says \"this is about to land\". */}\n          <motion.span\n            className=\"absolute inset-x-2 -bottom-1 h-5 rounded-[50%] bg-black blur-[6px]\"\n            style={{ opacity: shadowOpacity, scaleX: shadowScale }}\n          />\n\n          <motion.div\n            style={{\n              y: toolY,\n              opacity: toolOpacity,\n              rotate: toolRotate,\n              scaleX: squashX,\n              scaleY: squashY,\n            }}\n            // Squashes from the rubber face, not the middle of the handle.\n            className=\"absolute bottom-0 -left-1 w-[176px] origin-bottom\"\n          >\n            <StampTool ink={ink} />\n          </motion.div>\n        </div>\n      </motion.article>\n\n      <div className=\"flex flex-wrap justify-center gap-2\">\n        <StampButton\n          shortcut=\"A\"\n          onClick={() => stamp(\"approved\")}\n          active={verdict === \"approved\"}\n        >\n          Approve\n        </StampButton>\n        <StampButton\n          shortcut=\"R\"\n          onClick={() => stamp(\"rejected\")}\n          active={verdict === \"rejected\"}\n        >\n          Reject\n        </StampButton>\n        <StampButton shortcut=\"U\" onClick={undo} disabled={!impression}>\n          Undo\n        </StampButton>\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {verdict ? `${title} ${LABEL[verdict].toLowerCase()}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Ink({ mark, reduce }: { mark: Impression; reduce: boolean }) {\n  const filter = `ink-${useId().replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n  return (\n    <motion.svg\n      viewBox=\"0 0 168 56\"\n      className={cn(\n        \"absolute inset-0 size-full overflow-visible\",\n        // Ink soaks into the paper: it darkens what is under it in light mode\n        // and brightens it in dark mode, so the printed total shows through.\n        \"mix-blend-multiply dark:mix-blend-screen\",\n      )}\n      style={{ x: mark.x, rotate: mark.rotate, color: INK[mark.verdict] }}\n      initial={reduce ? { opacity: 0, y: mark.y } : { opacity: 0, y: mark.y, scale: 1.02 }}\n      animate={{ opacity: 0.9, y: mark.y, scale: 1 }}\n      // Lifts off softer and quicker than it went down.\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.15 } }\n          : {\n              opacity: 0,\n              y: mark.y - 8,\n              scale: 1.02,\n              transition: { duration: 0.18, ease: EASE_OUT },\n            }\n      }\n      transition={{ duration: 0.1, ease: EASE_OUT }}\n    >\n      <defs>\n        <filter\n          id={filter}\n          x=\"-4%\"\n          y=\"-10%\"\n          width=\"108%\"\n          height=\"120%\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          {/* Fine grain: speckled dropouts where the rubber missed. */}\n          <feTurbulence\n            type=\"fractalNoise\"\n            baseFrequency=\"0.9\"\n            numOctaves={2}\n            seed={mark.seed}\n            result=\"grain\"\n          />\n          <feColorMatrix\n            in=\"grain\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  -5 0 0 0 3.3\"\n            result=\"speckle\"\n          />\n          {/* Broad pressure: one side of a hand stamp always inks heavier. */}\n          <feTurbulence\n            type=\"fractalNoise\"\n            baseFrequency=\"0.02\"\n            numOctaves={1}\n            seed={mark.seed + 1}\n            result=\"pressure\"\n          />\n          <feColorMatrix\n            in=\"pressure\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  1.4 0 0 0 0.25\"\n            result=\"density\"\n          />\n          <feComposite in=\"SourceGraphic\" in2=\"speckle\" operator=\"in\" result=\"grained\" />\n          <feComposite in=\"grained\" in2=\"density\" operator=\"in\" result=\"inked\" />\n          {/* Rough edges: rubber and paper fibre wobble the outline. */}\n          <feTurbulence\n            type=\"fractalNoise\"\n            baseFrequency=\"0.05\"\n            numOctaves={2}\n            seed={mark.seed + 2}\n            result=\"wobble\"\n          />\n          <feDisplacementMap\n            in=\"inked\"\n            in2=\"wobble\"\n            scale={2.5}\n            xChannelSelector=\"R\"\n            yChannelSelector=\"G\"\n          />\n        </filter>\n      </defs>\n      <g filter={`url(#${filter})`} fill=\"none\" stroke=\"currentColor\">\n        <rect x=\"2\" y=\"2\" width=\"164\" height=\"52\" rx=\"7\" strokeWidth={3.5} />\n        <rect\n          x=\"7.5\"\n          y=\"7.5\"\n          width=\"153\"\n          height=\"41\"\n          rx=\"3.5\"\n          strokeWidth={1.25}\n        />\n        <text\n          x=\"84\"\n          y=\"29\"\n          textAnchor=\"middle\"\n          dominantBaseline=\"central\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          fontSize=\"22\"\n          fontWeight={800}\n          letterSpacing=\"3.5\"\n        >\n          {LABEL[mark.verdict].toUpperCase()}\n        </text>\n      </g>\n    </motion.svg>\n  );\n}\n\n// A front view of a desk stamp: a turned wooden knob and neck, a brass\n// ferrule, the wooden mount with its index label, the foam cushion and the\n// inked rubber die.\nfunction StampTool({ ink }: { ink: Verdict }) {\n  const id = useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n  return (\n    <svg viewBox=\"0 0 176 132\" className=\"w-full overflow-visible\" aria-hidden>\n      <defs>\n        {/* Light from the top left, as on the rest of the page. */}\n        <radialGradient id={`${id}-knob`} cx=\"0.36\" cy=\"0.3\" r=\"0.75\">\n          <stop offset=\"0\" stopColor={WOOD_LIGHT} />\n          <stop offset=\"0.55\" stopColor={WOOD} />\n          <stop offset=\"1\" stopColor={WOOD_DARK} />\n        </radialGradient>\n        <linearGradient id={`${id}-turned`} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">\n          <stop offset=\"0\" stopColor={WOOD_DARK} />\n          <stop offset=\"0.35\" stopColor={WOOD_LIGHT} />\n          <stop offset=\"1\" stopColor={WOOD_DARK} />\n        </linearGradient>\n        <linearGradient id={`${id}-block`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop offset=\"0\" stopColor={WOOD_LIGHT} />\n          <stop offset=\"1\" stopColor={WOOD} />\n        </linearGradient>\n      </defs>\n      {/* Knob and neck. */}\n      <path d=\"M80 36C80 44 82 48 82 58H94C94 48 96 44 96 36Z\" fill={`url(#${id}-turned)`} />\n      <ellipse cx=\"88\" cy=\"22\" rx=\"23\" ry=\"19\" fill={`url(#${id}-knob)`} />\n      <ellipse cx=\"80\" cy=\"13\" rx=\"8\" ry=\"4\" fill=\"oklch(1 0 0 / 0.35)\" />\n      {/* Brass ferrule. */}\n      <rect x=\"76\" y=\"56\" width=\"24\" height=\"10\" rx=\"2.5\" fill={BRASS} />\n      <rect x=\"76\" y=\"58\" width=\"24\" height=\"2\" fill=\"oklch(1 0 0 / 0.4)\" />\n      {/* The mount, with a little end grain and a lighter top edge. */}\n      <rect x=\"6\" y=\"66\" width=\"164\" height=\"42\" rx=\"6\" fill={`url(#${id}-block)`} />\n      <path\n        d=\"M16 76C50 73 70 79 104 76S150 74 160 77M14 98C48 95 80 101 120 97S152 96 162 99\"\n        fill=\"none\"\n        stroke={WOOD_DARK}\n        strokeOpacity={0.35}\n        strokeWidth={1}\n      />\n      <rect x=\"6\" y=\"66\" width=\"164\" height=\"2.5\" rx=\"1.25\" fill=\"oklch(1 0 0 / 0.35)\" />\n      {/* The index label, printed with what the die says. */}\n      <rect x=\"52\" y=\"78\" width=\"72\" height=\"18\" rx=\"2\" fill=\"oklch(0.97 0.01 90)\" />\n      <text\n        x=\"88\"\n        y=\"87.5\"\n        textAnchor=\"middle\"\n        dominantBaseline=\"central\"\n        fontSize=\"9\"\n        fontWeight={800}\n        letterSpacing=\"1.5\"\n        style={{ fill: INK[ink] }}\n      >\n        {LABEL[ink].toUpperCase()}\n      </text>\n      {/* Foam cushion and the rubber die, wet with ink. */}\n      <rect x=\"9\" y=\"108\" width=\"158\" height=\"6\" fill=\"oklch(0.3 0.01 60)\" />\n      <rect x=\"10\" y=\"114\" width=\"156\" height=\"18\" rx=\"2.5\" style={{ fill: INK[ink] }} />\n      <rect x=\"12\" y=\"115\" width=\"152\" height=\"2\" rx=\"1\" fill=\"oklch(1 0 0 / 0.25)\" />\n    </svg>\n  );\n}\n\nfunction StampButton({\n  shortcut,\n  active,\n  disabled,\n  onClick,\n  children,\n}: {\n  shortcut: string;\n  active?: boolean;\n  disabled?: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      aria-keyshortcuts={shortcut}\n      aria-pressed={disabled ? undefined : !!active}\n      className=\"flex h-10 touch-manipulation items-center gap-2.5 rounded-full bg-muted pr-2.5 pl-4 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale,opacity] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] disabled:opacity-50 disabled:active:scale-100 motion-reduce:transition-[opacity]\"\n    >\n      {children}\n      {/* A 20px chip 10px in from the pill's edge: 10 + 10 = 20 radius, so\n          a 5px corner keeps it reading as a key rather than a pill. */}\n      <kbd\n        className={cn(\n          \"flex size-5 items-center justify-center rounded-[5px] bg-background font-sans text-xs text-muted-foreground shadow-[0_0_0_1px_var(--border)] transition-[color] duration-150 ease-out\",\n          active && \"text-foreground\",\n        )}\n      >\n        {shortcut}\n      </kbd>\n    </button>\n  );\n}\n\nconst ITEMS = [\n  { label: \"Flights, BOM to GOI\", amount: \"$486.00\" },\n  { label: \"Hotel, 2 nights\", amount: \"$412.00\" },\n  { label: \"Team dinner\", amount: \"$96.40\" },\n];\n\nexport default function RubberStampDemo() {\n  const [verdict, setVerdict] = useState<Verdict | null>(null);\n  return (\n    <RubberStamp title=\"Expense request EXP-2041\" onChange={setVerdict}>\n      <div className=\"flex items-start justify-between gap-4\">\n        <div>\n          <p className=\"font-mono text-xs text-muted-foreground\">EXP-2041</p>\n          <h3 className=\"mt-1 text-lg font-medium tracking-tight text-foreground\">\n            Design offsite, Goa\n          </h3>\n          <p className=\"mt-0.5 text-sm text-muted-foreground\">Priya Shah, Product design</p>\n        </div>\n        <span\n          className={cn(\n            \"shrink-0 rounded-full bg-muted px-2.5 py-1 text-xs font-medium transition-[color] duration-150 ease-out\",\n            verdict === \"rejected\"\n              ? \"text-destructive\"\n              : verdict === \"approved\"\n                ? \"text-foreground\"\n                : \"text-muted-foreground\",\n          )}\n        >\n          {verdict ? LABEL[verdict] : \"Pending\"}\n        </span>\n      </div>\n      <dl className=\"mt-5 space-y-2 text-sm\">\n        {ITEMS.map((item) => (\n          <div key={item.label} className=\"flex justify-between gap-4\">\n            <dt className=\"text-muted-foreground\">{item.label}</dt>\n            <dd className=\"text-foreground tabular-nums\">{item.amount}</dd>\n          </div>\n        ))}\n      </dl>\n      {/* The bottom padding is the stamp's landing zone, so the impression\n          overlaps the total the way a real one would without hiding it. */}\n      <div className=\"mt-4 flex items-baseline justify-between border-t border-border pt-4 pb-14\">\n        <span className=\"text-sm text-muted-foreground\">Total</span>\n        <span className=\"text-xl font-medium tracking-tight text-foreground tabular-nums\">\n          $994.40\n        </span>\n      </div>\n    </RubberStamp>\n  );\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/rubber-stamp"}