{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tally",
  "type": "registry:ui",
  "title": "Tally",
  "description": "A count-up number built from odometer-style digit drums that enter with a soft white-blue cast and settle to white as they seat, with a brief neutral underline flash on settle. Locale and currency aware via Intl.NumberFormat.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/text/tally/tally.tsx",
      "type": "registry:ui",
      "target": "components/crucible/tally.tsx",
      "content": "\"use client\";\n\nimport { motion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface TallyProps\n  extends Omit<HTMLMotionProps<\"span\">, \"children\" | \"initial\" | \"whileInView\" | \"viewport\" | \"transition\"> {\n  /** The number counted up (or down) to. */\n  value: number;\n  /** Starting value each digit drum spins up (or down) from. @default 0 */\n  start?: number;\n  /** Decimal places to show. @default 0 */\n  decimals?: number;\n  /** BCP 47 locale passed to Intl.NumberFormat. @default \"en-US\" */\n  locale?: string;\n  /** Extra Intl.NumberFormat options, e.g. `{ style: \"currency\", currency: \"USD\" }`. */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Seconds each digit drum takes to settle. @default 1.4 */\n  duration?: number;\n  /** Seconds between each digit's settle, tail to head. @default 0.06 */\n  stagger?: number;\n  /** Seconds before the count-up starts. */\n  delay?: number;\n  /** Re-run every time the number re-enters the viewport (default: once). */\n  repeat?: boolean;\n  /** Color the digits settle to. @default \"#ffffff\" */\n  color?: string;\n  /** Hex hue of the entry cast, bloom, backlight and baseline glint. @default \"#93c5fd\" (cool white-blue) */\n  tint?: string;\n  /** Bloom on each drum plus the breathing backlight (and the static halo under reduced motion). Turn off for small, dense, or colored-chip readouts. @default true */\n  glow?: boolean;\n  /** The glint that sweeps the baseline as the value settles (a static rule under reduced motion). @default true */\n  glint?: boolean;\n  /** Cylinder shading over each drum face. Turn off on solid color fills (badges, chips), where it reads as a grey box. @default true */\n  shade?: boolean;\n}\n\nconst DIGIT_ROWS = \"0123456789\".split(\"\");\n\n/** Default palette: a cool white-blue cast (blue-200) over a blue-300 glow. */\nconst DEFAULT_CAST = \"191,219,254\";\nconst DEFAULT_GLOW = \"147,197,253\";\n\n/** \"r,g,b\" for a #rgb / #rrggbb hex, or null if it can't be parsed. */\nfunction rgbOf(hex: string | undefined): string | null {\n  if (!hex) return null;\n  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());\n  if (!m) return null;\n  const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join(\"\") : m[1];\n  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).join(\",\");\n}\n\n/** Vertical cylinder shading painted over each drum face: dark at the top and\n *  bottom of the slot with a faint specular band above centre. Over the dark\n *  stage the dark stops vanish; over the bright digit they carve a curved,\n *  machined drum surface behind the slot. Slate-tinted so the shade stays cool. */\nconst DRUM_FACE =\n  \"linear-gradient(to bottom,\" +\n  \"rgba(2,6,23,0.55) 0%,\" +\n  \"rgba(2,6,23,0) 26%,\" +\n  \"rgba(255,255,255,0.05) 43%,\" +\n  \"rgba(2,6,23,0) 60%,\" +\n  \"rgba(2,6,23,0.6) 100%)\";\n\n/** Soft top/bottom fade so digits read as emerging from behind a slot lip\n *  (and incoming/outgoing digits dissolve at the slot edges as the drum turns). */\nconst SLOT_MASK = \"linear-gradient(to bottom, transparent 0%, #000 11%, #000 89%, transparent 100%)\";\n\nfunction formatValue(\n  value: number,\n  decimals: number,\n  locale: string | undefined,\n  options?: Intl.NumberFormatOptions\n): string {\n  return new Intl.NumberFormat(locale, {\n    minimumFractionDigits: decimals,\n    maximumFractionDigits: decimals,\n    ...options,\n  }).format(value);\n}\n\n/**\n * Tally — a count-up number built from odometer-style digit drums. Each drum\n * enters carrying a cool white-blue cast and a soft bloom, then springs to its\n * seated digit and cools to white — the leading drum flaring brightest as it\n * seats. A vertical cylinder-shade and a slot fade give every drum physical\n * depth behind its window, a low backlight breathes under the readout, and a\n * glow sweep races along the baseline the instant the value settles. Falls back\n * to the correct static number (softly backlit, faint rest glow) under reduced\n * motion.\n */\nexport function Tally({\n  value,\n  start = 0,\n  decimals = 0,\n  locale = \"en-US\",\n  formatOptions,\n  duration = 1.4,\n  stagger = 0.06,\n  delay = 0,\n  repeat = false,\n  color = \"#ffffff\",\n  tint,\n  glow = true,\n  glint = true,\n  shade = true,\n  className,\n  ...props\n}: TallyProps) {\n  const reducedMotion = useReducedMotion();\n  const tintRgb = rgbOf(tint);\n  const castRgb = tintRgb ?? DEFAULT_CAST;\n  const glowRgb = tintRgb ?? DEFAULT_GLOW;\n  const CAST = `rgb(${castRgb})`;\n  const cast = (a: number) => `rgba(${castRgb},${a})`;\n  const halo = (a: number) => `rgba(${glowRgb},${a})`;\n  const noBloom = \"drop-shadow(0 0 0px rgba(0,0,0,0))\";\n  const targetStr = React.useMemo(\n    () => formatValue(value, decimals, locale, formatOptions),\n    [value, decimals, locale, formatOptions]\n  );\n  const chars = React.useMemo(() => [...targetStr], [targetStr]);\n  const totalDigits = React.useMemo(() => (targetStr.match(/[0-9]/g) ?? []).length, [targetStr]);\n  // Right-aligned so each drum starts on the matching digit of `start` (or \"0\" beyond its length).\n  const startDigitsFromEnd = React.useMemo(() => {\n    const startStr = formatValue(start, decimals, locale, formatOptions);\n    return (startStr.match(/[0-9]/g) ?? []).reverse();\n  }, [start, decimals, locale, formatOptions]);\n\n  if (reducedMotion) {\n    return (\n      <motion.span\n        data-crucible=\"tally\"\n        role=\"img\"\n        className={cn(\"relative isolate inline-flex items-baseline tabular-nums\", className)}\n        aria-label={targetStr}\n        {...props}\n      >\n        {glow && (\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[150%] w-[130%] -translate-x-1/2 -translate-y-1/2 rounded-full\"\n            style={{\n              background: `radial-gradient(ellipse at center, ${halo(0.12)}, ${halo(0)} 70%)`,\n              filter: \"blur(6px)\",\n            }}\n          />\n        )}\n        <span aria-hidden style={{ color, textShadow: glow ? `0 0 8px ${cast(0.14)}` : undefined }}>\n          {targetStr}\n        </span>\n        {glint && (\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute -bottom-1 left-0 h-[2px] w-full rounded-full\"\n            style={{\n              background: `linear-gradient(90deg, transparent, ${cast(0.5)}, transparent)`,\n              boxShadow: `0 0 8px ${halo(0.3)}`,\n            }}\n          />\n        )}\n      </motion.span>\n    );\n  }\n\n  type DrumCustom = { digit: number; startDigit: number; isLead: boolean };\n\n  // Bloom lives on a non-clipping wrapper so the drop-shadow escapes the slot\n  // clip. Lead digit flares brightest right as it seats, then cools to a faint\n  // resting halo; supporting digits swell more gently.\n  const bloomVariants: Variants = glow\n    ? {\n        hidden: { filter: `drop-shadow(0 0 9px ${halo(0.5)})` },\n        visible: (custom: DrumCustom) => ({\n          filter: custom.isLead\n            ? [`drop-shadow(0 0 9px ${halo(0.5)})`, `drop-shadow(0 0 20px ${cast(0.95)})`, `drop-shadow(0 0 6px ${cast(0.12)})`]\n            : [`drop-shadow(0 0 9px ${halo(0.45)})`, `drop-shadow(0 0 11px ${cast(0.5)})`, `drop-shadow(0 0 5px ${cast(0.1)})`],\n          transition: { duration, ease: \"easeOut\", times: custom.isLead ? [0, 0.85, 1] : [0, 0.55, 1] },\n        }),\n      }\n    : { hidden: { filter: noBloom }, visible: { filter: noBloom } };\n\n  const drumVariants: Variants = {\n    hidden: (custom: DrumCustom) => ({ y: `-${custom.startDigit}em`, color: CAST }),\n    visible: (custom: DrumCustom) => ({\n      y: `-${custom.digit}em`,\n      color,\n      transition: {\n        // Spring the reel so it overshoots a hair and settles — the drum's over-roll.\n        y: { type: \"spring\", duration, bounce: 0.2 },\n        color: { duration: duration * 0.75, ease: \"easeOut\" },\n      },\n    }),\n  };\n\n  const glyphVariants: Variants = {\n    hidden: { opacity: 0, y: \"0.25em\", color: CAST },\n    visible: {\n      opacity: 1,\n      y: \"0em\",\n      color,\n      transition: { duration: duration * 0.45, ease: \"easeOut\" },\n    },\n  };\n\n  // A bright hotspot sweeping across a mostly-transparent bar: a glint racing\n  // the baseline, not a flat line. Fades in as it enters, out as it leaves.\n  const underlineVariants: Variants = {\n    hidden: { opacity: 0, backgroundPositionX: \"120%\" },\n    visible: {\n      opacity: [0, 1, 1, 0],\n      backgroundPositionX: [\"120%\", \"-20%\"],\n      transition: {\n        delay: duration * 0.82,\n        duration: 0.8,\n        ease: \"easeOut\",\n        opacity: { delay: duration * 0.82, duration: 0.8, times: [0, 0.18, 0.6, 1] },\n      },\n    },\n  };\n\n  return (\n    <motion.span\n      data-crucible=\"tally\"\n      role=\"img\"\n      className={cn(\"relative isolate inline-flex items-baseline tabular-nums\", className)}\n      initial=\"hidden\"\n      whileInView=\"visible\"\n      viewport={{ once: !repeat, amount: 0.6 }}\n      transition={{ staggerChildren: stagger, delayChildren: delay }}\n      aria-label={targetStr}\n      {...props}\n    >\n      <style>{`\n        @keyframes crucible-tally-breathe {\n          0%, 100% { opacity: 0.32; transform: translate(-50%, -50%) scale(0.96); }\n          50% { opacity: 0.68; transform: translate(-50%, -50%) scale(1.05); }\n        }\n        [data-crucible=\"tally\"] .tally-backlight {\n          animation: crucible-tally-breathe 6s ease-in-out infinite;\n        }\n        @media (prefers-reduced-motion: reduce) {\n          [data-crucible=\"tally\"] .tally-backlight { animation: none; opacity: 0.4; }\n        }\n      `}</style>\n\n      {/* Idle life: a low backlight that breathes under the readout. */}\n      {glow && (\n        <span\n          aria-hidden\n          className=\"tally-backlight pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[150%] w-[130%] -translate-x-1/2 -translate-y-1/2 rounded-full\"\n          style={{\n            background: `radial-gradient(ellipse at center, ${halo(0.16)}, ${halo(0)} 70%)`,\n            filter: \"blur(6px)\",\n          }}\n        />\n      )}\n\n      {(() => {\n        let digitsSeen = 0;\n        return chars.map((char, i) => {\n          const digit = Number(char);\n          const isDigit = /[0-9]/.test(char) && !Number.isNaN(digit);\n          if (!isDigit) {\n            return (\n              <motion.span key={i} aria-hidden variants={glyphVariants} className=\"inline-block\">\n                {char}\n              </motion.span>\n            );\n          }\n          const distanceFromEnd = totalDigits - digitsSeen - 1;\n          const isLead = digitsSeen === 0;\n          digitsSeen += 1;\n          const startDigit = Number(startDigitsFromEnd[distanceFromEnd] ?? \"0\");\n          const custom: DrumCustom = { digit, startDigit, isLead };\n          return (\n            // Outer wrapper carries the bloom: it has no clip/mask, so the\n            // drop-shadow glow spreads freely past the slot.\n            <motion.span\n              key={i}\n              aria-hidden\n              custom={custom}\n              variants={bloomVariants}\n              className=\"inline-block align-baseline will-change-[filter]\"\n            >\n              <span\n                // clip-path (not overflow:hidden) clips the reel: overflow other\n                // than visible re-baselines an inline-block to its bottom margin\n                // edge, floating digits ~0.2em above sibling \"$\"/\",\"/\".\" glyphs.\n                // The invisible in-flow strut supplies the true text baseline\n                // (and the digit's natural width), and lineHeight:1em sizes the\n                // cell to exactly one reel row so neighbors never peek through.\n                // The mask fades the slot lips for drum depth.\n                className=\"relative inline-block align-baseline\"\n                style={{\n                  lineHeight: \"1em\",\n                  clipPath: \"inset(0)\",\n                  maskImage: SLOT_MASK,\n                  WebkitMaskImage: SLOT_MASK,\n                }}\n              >\n                <span className=\"invisible\">{char}</span>\n                <motion.span\n                  custom={custom}\n                  variants={drumVariants}\n                  className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-transform\"\n                >\n                  {DIGIT_ROWS.map((d) => (\n                    <span key={d} className=\"block text-center\" style={{ height: \"1em\", lineHeight: \"1em\" }}>\n                      {d}\n                    </span>\n                  ))}\n                </motion.span>\n                {/* Cylinder shade over the drum face: machined depth behind the slot. */}\n                {shade && (\n                  <span\n                    aria-hidden\n                    className=\"pointer-events-none absolute inset-0\"\n                    style={{ background: DRUM_FACE }}\n                  />\n                )}\n              </span>\n            </motion.span>\n          );\n        });\n      })()}\n\n      {glint && (\n        <motion.span\n          aria-hidden\n          variants={underlineVariants}\n          className=\"pointer-events-none absolute -bottom-1 left-0 h-[2px] w-full rounded-full\"\n          style={{\n            background: `linear-gradient(90deg, transparent 0%, transparent 38%, ${cast(0.95)} 50%, transparent 62%, transparent 100%)`,\n            backgroundSize: \"250% 100%\",\n            boxShadow: `0 0 10px ${halo(0.5)}`,\n          }}\n        />\n      )}\n    </motion.span>\n  );\n}\n"
    }
  ]
}