{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tip-jar","type":"registry:component","title":"Tip jar","description":"Coins drop into a glass jar and pile up as the total counts.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/tip-jar.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useAnimate,\n  useMotionValue,\n  useMotionValueEvent,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Coin = { id: number; value: number; leaving?: boolean };\ntype Body = {\n  id: number;\n  r: number;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  angle: number;\n};\n\n// The jar, in px. Coins fall in through the mouth at MOUTH_Y and rest on\n// the glass floor.\nconst JAR_W = 176;\nconst STAGE_H = 260;\n// 40px above the mouth is where tapped coins appear, just clear of the lid.\nconst MOUTH_Y = 40;\nconst GLASS = 8;\nconst FLOOR = STAGE_H - 12;\nconst LEFT = GLASS;\nconst RIGHT = JAR_W - GLASS;\n// The glass outlines. The inner wall sits 1px outside where coins stop, so\n// a resting coin touches the glass instead of overlapping it, and the base\n// is 10px of solid glass below the floor. The inner corners stay tight\n// (10px) because the physics floor is square: a rounder curve would let\n// resting coins sink into the glass.\nconst OUTER = `M2 ${MOUTH_Y + 8} V${FLOOR - 6} Q2 ${STAGE_H - 2} 18 ${STAGE_H - 2} H${JAR_W - 18} Q${JAR_W - 2} ${STAGE_H - 2} ${JAR_W - 2} ${FLOOR - 6} V${MOUTH_Y + 8}`;\nconst INNER = `M${GLASS - 1} ${MOUTH_Y + 8} V${FLOOR - 10} Q${GLASS - 1} ${FLOOR} ${GLASS + 9} ${FLOOR} H${JAR_W - GLASS - 9} Q${JAR_W - GLASS + 1} ${FLOOR} ${JAR_W - GLASS + 1} ${FLOOR - 10} V${MOUTH_Y + 8}`;\n\n// Tuned by feel: heavy enough that a coin drops like metal, a bounce that\n// dies after two or three hops, and slow impacts that don't bounce at all\n// so a pile can actually come to rest.\nconst GRAVITY = 2400;\nconst RESTITUTION = 0.32;\nconst REST_SPEED = 40;\nconst FLOOR_FRICTION = 0.86;\nconst SUBSTEPS = 4;\nconst ITERATIONS = 4;\n// Asleep once nothing has moved more than 0.2px a frame for 24 frames.\n// Position, not speed: in a pile the solver leaves small speeds that the\n// contacts cancel every frame, so speed alone would never settle.\nconst SLEEP_MOVE = 0.2;\nconst SLEEP_FRAMES = 24;\nconst MAX_THROW = 1400;\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst HOME = { type: \"spring\", stiffness: 500, damping: 34 } as const;\n\n// Bigger coins are worth more, like real money.\nfunction radius(value: number) {\n  return value >= 5 ? 18 : value >= 2 ? 16 : 14;\n}\n\n// A repeatable scatter for the coins the demo starts with, so the server and\n// the client agree on where they lie.\nfunction seeded(seed: number) {\n  let s = seed;\n  return () => {\n    s = (s * 16807) % 2147483647;\n    return s / 2147483647 - 0.5;\n  };\n}\n\n// Drops the starting coins one by one and lets each settle, so the jar\n// opens already holding a believable little pile.\nfunction pile(values: number[]) {\n  const rand = seeded(7);\n  const list: Body[] = [];\n  values.forEach((value, id) => {\n    const r = radius(value);\n    list.push({\n      id,\n      r,\n      x: JAR_W / 2 + rand() * 90,\n      y: MOUTH_Y - r,\n      vx: rand() * 120,\n      vy: 0,\n      angle: rand() * Math.PI * 2,\n    });\n    for (let i = 0; i < 90; i++) step(list, 1 / 60);\n  });\n  for (let i = 0; i < 240; i++) step(list, 1 / 60);\n  for (const b of list) b.vx = b.vy = 0;\n  return list;\n}\n\nfunction step(bodies: Body[], dt: number) {\n  const h = dt / SUBSTEPS;\n  for (let s = 0; s < SUBSTEPS; s++) {\n    for (const b of bodies) {\n      b.vy += GRAVITY * h;\n      b.x += b.vx * h;\n      b.y += b.vy * h;\n      // Rolling without slipping: distance over radius.\n      b.angle += (b.vx * h) / b.r;\n    }\n    for (let it = 0; it < ITERATIONS; it++) {\n      for (const b of bodies) {\n        if (b.x < LEFT + b.r) {\n          b.x = LEFT + b.r;\n          if (b.vx < 0) b.vx = -b.vx * RESTITUTION;\n        } else if (b.x > RIGHT - b.r) {\n          b.x = RIGHT - b.r;\n          if (b.vx > 0) b.vx = -b.vx * RESTITUTION;\n        }\n        if (b.y > FLOOR - b.r) {\n          b.y = FLOOR - b.r;\n          if (b.vy > 0) b.vy = b.vy < REST_SPEED ? 0 : -b.vy * RESTITUTION;\n          b.vx *= FLOOR_FRICTION;\n        }\n      }\n      for (let i = 0; i < bodies.length; i++) {\n        for (let j = i + 1; j < bodies.length; j++) {\n          const a = bodies[i];\n          const b = bodies[j];\n          const dx = b.x - a.x;\n          const dy = b.y - a.y;\n          const min = a.r + b.r;\n          const d2 = dx * dx + dy * dy;\n          if (d2 >= min * min || d2 === 0) continue;\n          const d = Math.sqrt(d2);\n          const nx = dx / d;\n          const ny = dy / d;\n          // Mass goes with area, so a big coin shoves a small one aside.\n          const ma = a.r * a.r;\n          const mb = b.r * b.r;\n          const push = (min - d) / (ma + mb);\n          a.x -= nx * push * mb;\n          a.y -= ny * push * mb;\n          b.x += nx * push * ma;\n          b.y += ny * push * ma;\n          const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;\n          if (vn >= 0) continue;\n          const e = -vn < REST_SPEED ? 0 : RESTITUTION;\n          const impulse = (-(1 + e) * vn) / (1 / ma + 1 / mb);\n          a.vx -= (impulse / ma) * nx;\n          a.vy -= (impulse / ma) * ny;\n          b.vx += (impulse / mb) * nx;\n          b.vy += (impulse / mb) * ny;\n          // Metal on metal grips a little, which is what lets coins stack\n          // instead of sliding off each other forever.\n          const tx = -ny;\n          const ty = nx;\n          const vt = (b.vx - a.vx) * tx + (b.vy - a.vy) * ty;\n          const grip = vt * 0.15;\n          a.vx += (grip * tx * mb) / (ma + mb);\n          a.vy += (grip * ty * mb) / (ma + mb);\n          b.vx -= (grip * tx * ma) / (ma + mb);\n          b.vy -= (grip * ty * ma) / (ma + mb);\n        }\n      }\n    }\n  }\n}\n\n// A random offset in [-spread / 2, spread / 2].\nfunction jitter(spread: number) {\n  return (Math.random() - 0.5) * spread;\n}\n\nfunction transformOf(b: Body) {\n  return `translate(${(b.x - b.r).toFixed(2)}px, ${(b.y - b.r).toFixed(2)}px) rotate(${b.angle.toFixed(3)}rad)`;\n}\n\nfunction place(el: HTMLElement, b: Body) {\n  el.style.transform = transformOf(b);\n}\n\nexport function TipJar({\n  denominations = [1, 2, 5],\n  initialCoins = [],\n  currency = \"$\",\n  capacity = 24,\n  onChange,\n  className,\n}: {\n  denominations?: number[];\n  // Coins already in the jar when it first renders.\n  initialCoins?: number[];\n  currency?: string;\n  capacity?: number;\n  onChange?: (total: number) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [start] = useState(() => {\n    const settled = pile(initialCoins);\n    return {\n      coins: initialCoins.map((value, id) => ({ id, value })) as Coin[],\n      bodies: settled,\n      // Written once into the markup, so the pile is in place before\n      // hydration instead of flashing in from the corner.\n      transforms: new Map(settled.map((b) => [b.id, transformOf(b)])),\n      total: initialCoins.reduce((sum, v) => sum + v, 0),\n    };\n  });\n  const [coins, setCoins] = useState<Coin[]>(start.coins);\n  const [ghost, setGhost] = useState<number | null>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const readoutRef = useRef<HTMLSpanElement>(null);\n  const bodies = useRef<Body[]>(start.bodies);\n  const els = useRef(new Map<number, HTMLElement>());\n  const nextId = useRef(start.coins.length);\n  const frame = useRef(0);\n  const [jarScope, animateJar] = useAnimate<HTMLDivElement>();\n\n  const format = (v: number) => `${currency}${v.toFixed(2)}`;\n  const total = coins.reduce((sum, c) => (c.leaving ? sum : sum + c.value), 0);\n  const count = coins.filter((c) => !c.leaving).length;\n  const full = count >= capacity;\n\n  // Counts up to the new total rather than jumping, so each coin reads as\n  // being added to what was there.\n  const shown = useMotionValue(start.total);\n  useMotionValueEvent(shown, \"change\", (v) => {\n    if (readoutRef.current) readoutRef.current.textContent = format(v);\n  });\n  useEffect(() => {\n    if (reduceMotion) {\n      shown.jump(total);\n      if (readoutRef.current) readoutRef.current.textContent = format(total);\n      return;\n    }\n    const controls = animate(shown, total, { duration: 0.5, ease: EASE_OUT });\n    return () => controls.stop();\n    // format only depends on currency, which the readout text covers.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [total, reduceMotion, shown]);\n\n  useEffect(() => {\n    const ref = frame;\n    return () => cancelAnimationFrame(ref.current);\n  }, []);\n\n  const render = () => {\n    for (const b of bodies.current) {\n      const el = els.current.get(b.id);\n      if (el) place(el, b);\n    }\n  };\n\n  // Runs only while something moves, then sleeps until the next coin.\n  const wake = () => {\n    if (frame.current) return;\n    if (reduceMotion) {\n      // Jump to where everything would come to rest.\n      for (let i = 0; i < 600; i++) step(bodies.current, 1 / 60);\n      for (const b of bodies.current) b.vx = b.vy = 0;\n      render();\n      return;\n    }\n    let last = 0;\n    let still = 0;\n    const tick = (now: number) => {\n      // The first frame only records the time.\n      const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 0;\n      last = now;\n      const list = bodies.current;\n      const before = list.map((b) => [b.x, b.y]);\n      step(list, dt);\n      render();\n      const moving = list.some(\n        (b, i) =>\n          Math.hypot(b.x - before[i][0], b.y - before[i][1]) > SLEEP_MOVE,\n      );\n      still = moving || !dt ? 0 : still + 1;\n      if (still > SLEEP_FRAMES) {\n        for (const b of list) b.vx = b.vy = 0;\n        frame.current = 0;\n        return;\n      }\n      frame.current = requestAnimationFrame(tick);\n    };\n    frame.current = requestAnimationFrame(tick);\n  };\n\n  const add = (\n    value: number,\n    at?: { x: number; y: number; vx: number; vy: number },\n  ) => {\n    if (full) return;\n    const r = radius(value);\n    const id = nextId.current++;\n    bodies.current.push({\n      id,\n      r,\n      // Dropped by button: a little off center, so coins don't stack in a\n      // perfect column.\n      x: Math.min(\n        Math.max(at?.x ?? JAR_W / 2 + jitter(48), LEFT + r),\n        RIGHT - r,\n      ),\n      y: Math.min(at?.y ?? r + 4, FLOOR - r),\n      vx: at?.vx ?? 0,\n      vy: at?.vy ?? 0,\n      angle: jitter(Math.PI * 2),\n    });\n    setCoins((c) => [...c, { id, value }]);\n    onChange?.(total + value);\n    wake();\n  };\n\n  const removeLast = () => {\n    const last = coins.findLast((c) => !c.leaving);\n    if (!last) return;\n    onChange?.(total - last.value);\n    const body = bodies.current.find((b) => b.id === last.id);\n    bodies.current = bodies.current.filter((b) => b.id !== last.id);\n    const el = els.current.get(last.id);\n    if (reduceMotion || !body || !el) {\n      setCoins((c) => c.filter((x) => x.id !== last.id));\n      wake();\n      return;\n    }\n    setCoins((c) =>\n      c.map((x) => (x.id === last.id ? { ...x, leaving: true } : x)),\n    );\n    // The jar tips and wobbles back; everything inside gets jostled.\n    animateJar(\n      jarScope.current,\n      { rotate: [0, -6, 4, -2, 0] },\n      { duration: 0.45, ease: \"easeInOut\" },\n    );\n    for (const b of bodies.current) {\n      b.vx += jitter(160);\n      b.vy -= 140 + jitter(120);\n    }\n    wake();\n    // The last coin hops out through the mouth and fades as it clears.\n    const side = body.x < JAR_W / 2 ? -1 : 1;\n    const from = `translate(${body.x - body.r}px, ${body.y - body.r}px) rotate(${body.angle}rad)`;\n    const to = `translate(${body.x - body.r + side * 28}px, ${MOUTH_Y - 80}px) rotate(${body.angle + side * 2}rad)`;\n    const exit = el.animate(\n      [\n        { transform: from, opacity: 1 },\n        { transform: to, opacity: 0 },\n      ],\n      {\n        duration: 360,\n        easing: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n        fill: \"forwards\",\n      },\n    );\n    exit.onfinish = () => setCoins((c) => c.filter((x) => x.id !== last.id));\n  };\n\n  // Dragging a coin from the tray.\n  const drag = useRef<{\n    pointer: number;\n    value: number;\n    home: { x: number; y: number };\n    offset: { x: number; y: number };\n    start: { x: number; y: number };\n    last: { x: number; y: number; t: number };\n    v: { x: number; y: number };\n    active: boolean;\n  } | null>(null);\n  const suppressClick = useRef(false);\n  const gx = useMotionValue(0);\n  const gy = useMotionValue(0);\n\n  const toRoot = (clientX: number, clientY: number) => {\n    const box = rootRef.current!.getBoundingClientRect();\n    return { x: clientX - box.left, y: clientY - box.top };\n  };\n\n  const release = (cancelled = false) => {\n    const d = drag.current;\n    drag.current = null;\n    if (!d?.active) return;\n    suppressClick.current = true;\n    const stage = stageRef.current!.getBoundingClientRect();\n    const root = rootRef.current!.getBoundingClientRect();\n    const cx = gx.get() + root.left - stage.left;\n    const cy = gy.get() + root.top - stage.top;\n    const r = radius(d.value);\n    const overJar = cx > -r && cx < JAR_W + r && cy < FLOOR && cy > -r * 2;\n    if (overJar && !full && !cancelled) {\n      const clamp = (v: number) => Math.min(Math.max(v, -MAX_THROW), MAX_THROW);\n      setGhost(null);\n      add(d.value, { x: cx, y: cy, vx: clamp(d.v.x), vy: clamp(d.v.y) });\n      return;\n    }\n    // Missed the jar: it springs home, carrying the throw's speed.\n    Promise.all([\n      animate(gx, d.home.x, { ...HOME, velocity: d.v.x }),\n      animate(gy, d.home.y, { ...HOME, velocity: d.v.y }),\n    ]).then(() => {\n      if (!drag.current) setGhost(null);\n    });\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative w-[min(420px,100%)] rounded-[20px] bg-muted p-5 shadow-raised select-none\",\n        className,\n      )}\n    >\n      <div className=\"flex items-start justify-between gap-4\">\n        <div>\n          <h2 className=\"text-base font-medium text-foreground\">Leave a tip</h2>\n          <p className=\"mt-1 text-sm text-muted-foreground\" aria-live=\"polite\">\n            {full ? \"The jar is full. Thank you!\" : \"Tap a coin, or throw one in.\"}\n          </p>\n        </div>\n        <div className=\"text-right\">\n          <span\n            ref={readoutRef}\n            aria-hidden\n            className=\"block text-3xl font-semibold tracking-tight text-foreground tabular-nums\"\n          >\n            {format(start.total)}\n          </span>\n          <span className=\"text-xs text-muted-foreground tabular-nums\">\n            {count} {count === 1 ? \"coin\" : \"coins\"}\n          </span>\n        </div>\n      </div>\n      <output className=\"sr-only\" aria-live=\"polite\">\n        Tip total {format(total)}\n      </output>\n\n      <div\n        className=\"relative mx-auto mt-3\"\n        style={{ width: JAR_W, height: STAGE_H }}\n        ref={stageRef}\n      >\n        {/* Where the jar meets the table. */}\n        <span\n          aria-hidden\n          className=\"absolute -inset-x-1 -bottom-1.5 h-3 rounded-[50%] bg-black/10 blur-[5px] dark:bg-black/60\"\n        />\n        <div\n          ref={jarScope}\n          className=\"absolute inset-0\"\n          style={{ transformOrigin: `50% ${FLOOR}px` }}\n        >\n          <svg\n            aria-hidden\n            viewBox={`0 0 ${JAR_W} ${STAGE_H}`}\n            className=\"absolute inset-0 size-full overflow-visible\"\n          >\n            {/* The inside of the jar, seen through the far wall. */}\n            <path d={`${INNER} Z`} className=\"fill-foreground/[0.035]\" />\n          </svg>\n\n          {coins.map((coin) => {\n            const r = radius(coin.value);\n            return (\n              <div\n                key={coin.id}\n                ref={(el) => {\n                  if (!el) {\n                    els.current.delete(coin.id);\n                    return;\n                  }\n                  els.current.set(coin.id, el);\n                  const body = bodies.current.find((b) => b.id === coin.id);\n                  if (body && !coin.leaving) place(el, body);\n                }}\n                className=\"absolute top-0 left-0 transition-[opacity] duration-150 ease-out will-change-transform starting:opacity-0\"\n                style={{\n                  width: r * 2,\n                  height: r * 2,\n                  transform: start.transforms.get(coin.id),\n                }}\n              >\n                <CoinFace value={coin.value} currency={currency} />\n              </div>\n            );\n          })}\n\n          <svg\n            aria-hidden\n            viewBox={`0 0 ${JAR_W} ${STAGE_H}`}\n            className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n            fill=\"none\"\n          >\n            {/* The glass itself: the band between the outer and inner\n                walls, thickest at the base where real jars are poured\n                heavy. Filling it tints whatever sits behind the wall. */}\n            <path\n              d={`${OUTER} Z ${INNER} Z`}\n              fillRule=\"evenodd\"\n              className=\"fill-foreground/[0.07]\"\n            />\n            <path d={OUTER} strokeWidth={1.25} className=\"stroke-foreground/25\" />\n            <path d={INNER} strokeWidth={1} className=\"stroke-foreground/10\" />\n            {/* Specular light on curved glass is white in any room, so\n                these are plain white at a theme-dependent strength. */}\n            <rect\n              x=\"11\"\n              y={MOUTH_Y + 20}\n              width=\"5\"\n              height={FLOOR - MOUTH_Y - 66}\n              rx=\"2.5\"\n              className=\"fill-white/70 dark:fill-white/[0.14]\"\n            />\n            <rect\n              x=\"19\"\n              y={MOUTH_Y + 26}\n              width=\"1.5\"\n              height=\"46\"\n              rx=\"0.75\"\n              className=\"fill-white/60 dark:fill-white/10\"\n            />\n            <rect\n              x={JAR_W - 14}\n              y={MOUTH_Y + 24}\n              width=\"3\"\n              height=\"52\"\n              rx=\"1.5\"\n              className=\"fill-white/60 dark:fill-white/10\"\n            />\n            <path\n              d={`M30 ${FLOOR + 5} H${JAR_W - 30}`}\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              className=\"stroke-white/70 dark:stroke-white/10\"\n            />\n            {/* The threaded neck, drawn in front so coins drop in behind\n                it. */}\n            <rect\n              x=\"0.75\"\n              y={MOUTH_Y - 6}\n              width={JAR_W - 1.5}\n              height=\"16\"\n              rx=\"5\"\n              strokeWidth={1.25}\n              className=\"fill-muted stroke-foreground/25\"\n            />\n            <rect\n              x=\"0.75\"\n              y={MOUTH_Y - 6}\n              width={JAR_W - 1.5}\n              height=\"16\"\n              rx=\"5\"\n              className=\"fill-foreground/[0.06]\"\n            />\n            <path\n              d={`M6 ${MOUTH_Y - 1} L${JAR_W - 6} ${MOUTH_Y + 2} M6 ${MOUTH_Y + 4} L${JAR_W - 6} ${MOUTH_Y + 7}`}\n              strokeWidth={1}\n              strokeLinecap=\"round\"\n              className=\"stroke-foreground/15\"\n            />\n            <path\n              d={`M8 ${MOUTH_Y - 3.5} H${JAR_W * 0.45}`}\n              strokeWidth={1.25}\n              strokeLinecap=\"round\"\n              className=\"stroke-white/80 dark:stroke-white/15\"\n            />\n          </svg>\n\n          {/* A kraft paper label stuck on the front. Coins pile up behind\n              it, the way they would behind a real one. Kraft and its\n              stamped ink are physical materials, so they are raw colors;\n              the dark theme only dims the room light on them. */}\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute left-1/2 flex w-[96px] -translate-x-1/2 -rotate-3 flex-col items-center rounded-[3px] py-2 shadow-[0_1px_2px_oklch(0_0_0/0.18)] dark:shadow-[0_1px_2px_oklch(0_0_0/0.6)]\"\n            style={{\n              top: MOUTH_Y + 30,\n              backgroundColor: \"light-dark(oklch(0.84 0.05 72), oklch(0.7 0.045 70))\",\n              color: \"oklch(0.32 0.04 55)\",\n            }}\n          >\n            <span className=\"text-[13px] leading-5 font-semibold tracking-[0.24em]\">\n              TIPS\n            </span>\n            <span className=\"text-xs leading-4 opacity-75\">thank you</span>\n            {/* A dashed stitch border, like a printed tag. */}\n            <span className=\"absolute inset-1 rounded-[2px] border border-dashed border-current opacity-25\" />\n          </div>\n\n          <button\n            type=\"button\"\n            aria-label={\n              count\n                ? `Tip jar, ${format(total)}. Shake out the last coin`\n                : \"Tip jar, empty\"\n            }\n            onClick={removeLast}\n            className=\"absolute inset-x-0 touch-manipulation rounded-t-[6px] rounded-b-[22px] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n            style={{ top: MOUTH_Y - 6, height: STAGE_H - (MOUTH_Y - 6) }}\n          />\n        </div>\n      </div>\n\n      <div className=\"mt-4 flex items-center justify-between gap-3\">\n        <div\n          className=\"flex items-center gap-1\"\n          role=\"group\"\n          aria-label=\"Add a coin\"\n        >\n          {denominations.map((value) => (\n            <button\n              key={value}\n              type=\"button\"\n              disabled={full}\n              aria-label={`Add ${format(value)}`}\n              className=\"flex size-11 cursor-grab touch-none items-center justify-center rounded-full outline-hidden transition-[scale,opacity] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40\"\n              onClick={() => {\n                if (suppressClick.current) {\n                  suppressClick.current = false;\n                  return;\n                }\n                add(value);\n              }}\n              onPointerDown={(e) => {\n                if (e.button !== 0 || drag.current || full) return;\n                suppressClick.current = false;\n                const box = e.currentTarget.getBoundingClientRect();\n                const home = toRoot(\n                  box.left + box.width / 2,\n                  box.top + box.height / 2,\n                );\n                const p = toRoot(e.clientX, e.clientY);\n                drag.current = {\n                  pointer: e.pointerId,\n                  value,\n                  home,\n                  offset: { x: p.x - home.x, y: p.y - home.y },\n                  start: p,\n                  last: { ...p, t: e.timeStamp },\n                  v: { x: 0, y: 0 },\n                  active: false,\n                };\n                e.currentTarget.setPointerCapture(e.pointerId);\n              }}\n              onPointerMove={(e) => {\n                const d = drag.current;\n                if (!d || d.pointer !== e.pointerId) return;\n                const p = toRoot(e.clientX, e.clientY);\n                // 4px of slop keeps a click a click.\n                if (\n                  !d.active &&\n                  Math.hypot(p.x - d.start.x, p.y - d.start.y) < 4\n                )\n                  return;\n                if (!d.active) {\n                  d.active = true;\n                  setGhost(value);\n                }\n                const dt = Math.max(e.timeStamp - d.last.t, 1) / 1000;\n                // Smoothed, so one uneven sample can't decide the throw.\n                d.v.x = d.v.x * 0.5 + ((p.x - d.last.x) / dt) * 0.5;\n                d.v.y = d.v.y * 0.5 + ((p.y - d.last.y) / dt) * 0.5;\n                d.last = { ...p, t: e.timeStamp };\n                gx.set(p.x - d.offset.x);\n                gy.set(p.y - d.offset.y);\n              }}\n              onPointerUp={(e) => {\n                if (drag.current?.pointer === e.pointerId) release();\n              }}\n              onPointerCancel={(e) => {\n                if (drag.current?.pointer !== e.pointerId) return;\n                drag.current.v = { x: 0, y: 0 };\n                release(true);\n              }}\n            >\n              <span\n                className=\"pointer-events-none\"\n                style={{ width: radius(value) * 2, height: radius(value) * 2 }}\n              >\n                <CoinFace value={value} currency={currency} />\n              </span>\n            </button>\n          ))}\n        </div>\n        <button\n          type=\"button\"\n          onClick={removeLast}\n          disabled={count === 0}\n          className=\"h-9 touch-manipulation rounded-full bg-background px-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-40 disabled:active:scale-100\"\n        >\n          Remove last\n        </button>\n      </div>\n\n      {ghost !== null && (\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute top-0 left-0 z-20\"\n          style={{\n            x: gx,\n            y: gy,\n            width: radius(ghost) * 2,\n            height: radius(ghost) * 2,\n            marginLeft: -radius(ghost),\n            marginTop: -radius(ghost),\n          }}\n        >\n          <CoinFace value={ghost} currency={currency} lifted />\n        </motion.div>\n      )}\n    </div>\n  );\n}\n\nfunction CoinFace({\n  value,\n  currency,\n  lifted = false,\n}: {\n  value: number;\n  currency: string;\n  lifted?: boolean;\n}) {\n  // Small change is nickel, the middle coin is bimetallic (a brass ring\n  // around a nickel core), the big one is solid brass.\n  const rim = value >= 2 ? BRASS : NICKEL;\n  const core = value >= 5 ? BRASS : NICKEL;\n  return (\n    <span\n      className=\"relative block size-full rounded-full\"\n      style={{\n        // A raised rim turned on a lathe: the conic sweep is the brushed\n        // edge catching light at two points, the radial is the dome.\n        background: `radial-gradient(circle at 34% 28%, oklch(1 0 0 / 0.55), transparent 52%), conic-gradient(from 210deg, ${rim.hi}, ${rim.mid} 22%, ${rim.lo} 42%, ${rim.mid} 58%, ${rim.hi} 74%, ${rim.mid} 88%, ${rim.hi})`,\n        boxShadow: lifted\n          ? `inset 0 0 0 0.5px ${rim.lo}, 0 10px 16px -6px oklch(0 0 0 / 0.4)`\n          : `inset 0 0 0 0.5px ${rim.lo}, 0 1px 1.5px oklch(0 0 0 / 0.35)`,\n      }}\n    >\n      {/* The sunken field, with the value struck in relief. */}\n      <span\n        className=\"absolute inset-[14%] grid place-items-center rounded-full font-semibold tabular-nums\"\n        style={{\n          background: `radial-gradient(circle at 38% 30%, ${core.hi}, ${core.mid} 58%, ${core.lo})`,\n          boxShadow: `inset 0 1px 1.5px oklch(0 0 0 / 0.35), inset 0 -1px 1px oklch(1 0 0 / 0.35)`,\n          color: core.ink,\n          fontSize: value >= 5 ? 13 : 12,\n          letterSpacing: \"-0.02em\",\n          textShadow:\n            \"0 -0.5px 0 oklch(1 0 0 / 0.6), 0 0.75px 0 oklch(0 0 0 / 0.25)\",\n        }}\n      >\n        {currency}\n        {value}\n      </span>\n    </span>\n  );\n}\n\n// Coin metals are physical materials, so they are raw colors that stay the\n// same in either theme: highlight, body, shadow, and the tone a struck\n// number takes on that metal.\nconst NICKEL = {\n  hi: \"oklch(0.96 0.004 250)\",\n  mid: \"oklch(0.82 0.008 250)\",\n  lo: \"oklch(0.6 0.012 250)\",\n  ink: \"oklch(0.4 0.014 250)\",\n};\nconst BRASS = {\n  hi: \"oklch(0.95 0.085 95)\",\n  mid: \"oklch(0.8 0.12 84)\",\n  lo: \"oklch(0.6 0.11 70)\",\n  ink: \"oklch(0.42 0.08 62)\",\n};\n\nexport default function TipJarDemo() {\n  return <TipJar initialCoins={[2, 1, 5, 1, 2, 1]} />;\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/tip-jar"}