{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tally-counter","type":"registry:component","title":"Tally counter","description":"Counts in hand-drawn tally marks, one pen stroke at a time.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/tally-counter.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Five groups of five per row, two rows: 50 marks fill the sheet exactly.\nconst PER_ROW = 5;\nconst ROWS = 2;\nconst CAPACITY = PER_ROW * 5 * ROWS;\nconst GROUP_W = 68;\nconst ROW_H = 66;\nconst PAD_X = 18;\nconst PAD_Y = 12;\nconst VIEW_W = PAD_X * 2 + GROUP_W * PER_ROW - 16;\nconst VIEW_H = PAD_Y * 2 + ROW_H * ROWS - 14;\n\n// A pen accelerates off the mark and eases into the end of the line.\nconst PEN = \"cubic-bezier(0.65, 0, 0.35, 1)\";\n\n// Tiny deterministic PRNG so every mark keeps its shape across renders and\n// the sheet draws the same on the server and the client.\nfunction rng(seed: number) {\n  let t = seed >>> 0;\n  return () => {\n    t += 0x6d2b79f5;\n    let r = Math.imul(t ^ (t >>> 15), 1 | t);\n    r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);\n    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\ntype Stroke = { d: string; width: number; diagonal: boolean };\n\nfunction strokeFor(index: number, seed: number): Stroke {\n  const r = rng(seed * 7919 + index * 104729);\n  const j = (n: number) => (r() * 2 - 1) * n;\n  const group = Math.floor(index / 5);\n  const slot = index % 5;\n  const gx = PAD_X + (group % PER_ROW) * GROUP_W;\n  const gy = PAD_Y + Math.floor(group / PER_ROW) * ROW_H;\n\n  let x0: number, y0: number, x1: number, y1: number;\n  if (slot < 4) {\n    x0 = gx + 6 + slot * 11 + j(1.2);\n    y0 = gy + 2 + j(1.5);\n    // Hands drift: the foot lands a little off from the head.\n    x1 = x0 + j(2.2);\n    y1 = gy + 46 + j(2);\n  } else {\n    // The gate stroke starts low-left and crosses all four.\n    x0 = gx - 2 + j(1.5);\n    y0 = gy + 38 + j(2);\n    x1 = gx + 48 + j(2);\n    y1 = gy + 8 + j(2);\n  }\n  // Bow the line sideways a touch, never enough to read as a curve.\n  const len = Math.hypot(x1 - x0, y1 - y0);\n  const nx = -(y1 - y0) / len;\n  const ny = (x1 - x0) / len;\n  const bow = j(1.8);\n  const cx = (x0 + x1) / 2 + nx * bow;\n  const cy = (y0 + y1) / 2 + ny * bow;\n  // A small flick where the pen lifts off.\n  const fx = slot < 4 ? j(1.2) - 1 : 1.5 + r();\n  const fy = slot < 4 ? 1 + r() : -1 - r();\n  const n = (v: number) => v.toFixed(1);\n  return {\n    d: `M${n(x0)} ${n(y0)}Q${n(cx)} ${n(cy)} ${n(x1)} ${n(y1)}l${n(fx)} ${n(fy)}`,\n    width: 2.3 + r() * 0.6,\n    diagonal: slot === 4,\n  };\n}\n\nexport function TallyCounter({\n  value,\n  onChange,\n  label,\n  min = 0,\n  max = CAPACITY,\n  seed = 1,\n  className,\n}: {\n  value: number;\n  onChange: (value: number) => void;\n  label: string;\n  min?: number;\n  /** The sheet holds at most 50 marks. */\n  max?: number;\n  seed?: number;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const limit = Math.min(max, CAPACITY);\n  const strokes = useMemo(\n    () => Array.from({ length: limit }, (_, i) => strokeFor(i, seed)),\n    [limit, seed],\n  );\n\n  // Which way the last change went, so the number rolls the same way.\n  const [last, setLast] = useState(value);\n  const [direction, setDirection] = useState(1);\n  if (last !== value) {\n    setLast(value);\n    setDirection(value > last ? 1 : -1);\n  }\n\n  const set = (next: number) => {\n    const clamped = Math.min(Math.max(next, min), limit);\n    if (clamped !== value) onChange(clamped);\n  };\n\n  return (\n    <div className={cn(\"w-[min(400px,100%)]\", className)}>\n      <div className=\"mb-3 flex items-end justify-between gap-4\">\n        <div>\n          <p className=\"mb-1 text-sm font-medium text-muted-foreground\">{label}</p>\n          <p\n            aria-hidden\n            className=\"relative h-12 overflow-hidden text-5xl leading-none font-semibold tracking-tight text-foreground tabular-nums\"\n          >\n            {/* The number steps up or down with the pen: a short roll, so a\n                run of fast taps still reads as counting. */}\n            <AnimatePresence\n              initial={false}\n              mode=\"popLayout\"\n              custom={direction}\n            >\n              <motion.span\n                key={value}\n                custom={direction}\n                className=\"block\"\n                variants={{\n                  enter: (d: number) =>\n                    reduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: `${d * 45}%`, filter: \"blur(4px)\" },\n                  center: { opacity: 1, y: \"0%\", filter: \"blur(0px)\" },\n                  exit: (d: number) =>\n                    reduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, y: `${d * -45}%`, filter: \"blur(4px)\" },\n                }}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n              >\n                {value}\n              </motion.span>\n            </AnimatePresence>\n          </p>\n        </div>\n        <div className=\"flex gap-2\">\n          <StepButton\n            label={`Remove one from ${label}`}\n            disabled={value <= min}\n            onClick={() => set(value - 1)}\n          >\n            <path d=\"M4 8h8\" />\n          </StepButton>\n          <StepButton\n            label={`Add one to ${label}`}\n            disabled={value >= limit}\n            onClick={() => set(value + 1)}\n          >\n            <path d=\"M8 4v8M4 8h8\" />\n          </StepButton>\n        </div>\n      </div>\n\n      <div\n        role=\"spinbutton\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuenow={value}\n        aria-valuemin={min}\n        aria-valuemax={limit}\n        aria-valuetext={`${value} ${value === 1 ? \"mark\" : \"marks\"}`}\n        onClick={() => set(value + 1)}\n        onKeyDown={(e) => {\n          const next = {\n            ArrowUp: value + 1,\n            ArrowRight: value + 1,\n            ArrowDown: value - 1,\n            ArrowLeft: value - 1,\n            PageUp: value + 5,\n            PageDown: value - 5,\n            Home: min,\n            End: limit,\n          }[e.key];\n          if (next === undefined) return;\n          e.preventDefault();\n          set(next);\n        }}\n        className=\"relative block cursor-pointer touch-manipulation rounded-2xl bg-background px-2 pt-6 pb-2 shadow-raised outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n      >\n        {/* A pocket notebook's wire binding: a row of punched holes along\n            the top of the page, spaced to fit whole holes at any width. */}\n        <span\n          aria-hidden\n          className=\"absolute inset-x-5 top-2.5 h-2 bg-[radial-gradient(circle,var(--color-muted)_2.5px,color-mix(in_oklab,var(--color-foreground)_14%,transparent)_3px,transparent_3.5px)] bg-[length:16px_8px] [background-repeat:space_no-repeat]\"\n        />\n        <svg\n          viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}\n          className=\"block h-auto w-full text-foreground\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n        >\n          {/* Faint rules under each row, like a pocket notebook. */}\n          {Array.from({ length: ROWS }, (_, row) => (\n            <line\n              key={row}\n              x1={8}\n              x2={VIEW_W - 8}\n              y1={PAD_Y + row * ROW_H + 52}\n              y2={PAD_Y + row * ROW_H + 52}\n              className=\"stroke-foreground/10\"\n              strokeWidth={1}\n            />\n          ))}\n          {strokes.map((s, i) => {\n            const drawn = i < value;\n            // Drawing snaps the ink on and runs the pen; erasing fades the\n            // ink first, then rewinds the dash once it is invisible, so a\n            // fast re-add always draws from the start.\n            const transition = reduceMotion\n              ? \"none\"\n              : drawn\n                ? `stroke-dashoffset ${s.diagonal ? 220 : 160}ms ${PEN}, opacity 0s`\n                : \"opacity 180ms ease-out, stroke-dashoffset 0s 180ms\";\n            return (\n              <path\n                key={i}\n                d={s.d}\n                pathLength={1}\n                strokeWidth={s.width}\n                // Gap longer than the path so the dash never wraps around.\n                strokeDasharray=\"1 2\"\n                style={{\n                  strokeDashoffset: drawn ? 0 : 1,\n                  opacity: drawn ? 1 : 0,\n                  transition,\n                }}\n              />\n            );\n          })}\n        </svg>\n      </div>\n      <p className=\"mt-2 text-xs text-muted-foreground\">\n        Tap the sheet or use the arrow keys\n      </p>\n    </div>\n  );\n}\n\nfunction StepButton({\n  label,\n  disabled,\n  onClick,\n  children,\n}: {\n  label: string;\n  disabled: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      // The sheet is the single tab stop, as in the spinbutton pattern.\n      tabIndex={-1}\n      aria-label={label}\n      disabled={disabled}\n      onClick={onClick}\n      className=\"flex size-11 touch-manipulation items-center justify-center rounded-full bg-muted text-foreground shadow-raised outline-hidden transition-[scale,opacity] duration-150 ease-out select-none active:scale-[0.96] disabled:opacity-40 disabled:active:scale-100\"\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.75}\n        strokeLinecap=\"round\"\n        aria-hidden\n      >\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nexport default function TallyCounterDemo() {\n  // Opens a row and a bit in: the sheet looks used, with room to go.\n  const [count, setCount] = useState(28);\n  return <TallyCounter label=\"Laps\" value={count} onChange={setCount} />;\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/tally-counter"}