{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "uai-number-tick",
  "title": "Uai Number Tick",
  "description": "Moves only the characters of a formatted number that changed, in one grid cell per character so widths never jump. Shared by Metric Card and Price Summary.",
  "registryDependencies": [
    "https://uaiblocks.dev/r/uai-theme.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/lib/uai-number-tick.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\nconst EASE_OUT_QUINT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\nexport type NumberTickDirection = \"up\" | \"down\";\nexport type NumberTickProps = {\n  /** The formatted value, for example \"$48,290\" or \"R$ 1.234,56\". */\n  value: string;\n  /** Which way changed characters move. Defaults to the sign of the change in the digits. */\n  direction?: NumberTickDirection;\n};\n\ntype Tick = { from: string; to: string; direction: NumberTickDirection; id: number };\n\nfunction digits(value: string) {\n  const only = value.replace(/[^\\d]/g, \"\");\n  return only ? Number(only) * (/^[\\s\\p{Sc}]*[-−]/u.test(value) ? -1 : 1) : 0;\n}\n\n/** Aligns two strings on the right, so units and decimals line up when the length changes. */\nfunction cells(from: string, to: string) {\n  const length = Math.max(from.length, to.length);\n  const a = from.padStart(length, \"\\u0000\");\n  const b = to.padStart(length, \"\\u0000\");\n  return Array.from({ length }, (_, index) => {\n    const before = a[index] === \"\\u0000\" ? \"\" : (a[index] ?? \"\");\n    const after = b[index] === \"\\u0000\" ? \"\" : (b[index] ?? \"\");\n    return { before, after, changed: before !== after };\n  });\n}\n\n/**\n * Renders a formatted number and, when it changes, moves only the characters that changed:\n * the old one leaves 3px one way while the new one arrives from 3px the other way, in 200ms\n * quint, both in one grid cell so the width never jumps. Reduced motion crossfades in 120ms.\n * At rest it renders the plain string, and the animated copy is hidden from assistive tech.\n */\nexport function NumberTick({ value, direction }: NumberTickProps) {\n  const [tick, setTick] = useState<Tick | null>(null);\n  const [current, setCurrent] = useState(value);\n  if (current !== value) {\n    setCurrent(value);\n    const delta = digits(value) - digits(current);\n    setTick({\n      from: current,\n      to: value,\n      direction: direction ?? (delta < 0 ? \"down\" : \"up\"),\n      id: (tick?.id ?? 0) + 1,\n    });\n  }\n  const ref = useRef<HTMLSpanElement>(null);\n\n  useIsomorphicLayoutEffect(() => {\n    if (!tick) return;\n    const root = ref.current;\n    if (!root || typeof root.animate !== \"function\") return setTick(null);\n    const reduce = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    const sign = tick.direction === \"up\" ? 1 : -1;\n    const options = { duration: reduce ? 120 : 200, easing: reduce ? \"ease-out\" : EASE_OUT_QUINT };\n    const animations = [\n      ...Array.from(root.querySelectorAll<HTMLElement>(\"[data-tick=out]\"), (element) =>\n        element.animate(\n          reduce\n            ? [{ opacity: 1 }, { opacity: 0 }]\n            : [\n                { opacity: 1, translate: \"0 0\" },\n                { opacity: 0, translate: `0 ${-3 * sign}px` },\n              ],\n          { ...options, fill: \"forwards\" },\n        ),\n      ),\n      ...Array.from(root.querySelectorAll<HTMLElement>(\"[data-tick=in]\"), (element) =>\n        element.animate(\n          reduce\n            ? [{ opacity: 0 }, { opacity: 1 }]\n            : [\n                { opacity: 0, translate: `0 ${3 * sign}px` },\n                { opacity: 1, translate: \"0 0\" },\n              ],\n          options,\n        ),\n      ),\n    ];\n    const id = tick.id;\n    Promise.all(animations.map((animation) => animation.finished))\n      .then(() => setTick((now) => (now?.id === id ? null : now)))\n      .catch(() => {});\n  }, [tick]);\n\n  if (!tick) return value;\n  return (\n    <span ref={ref} data-slot=\"number-tick\" className=\"inline-flex tabular-nums\">\n      <span className=\"sr-only\">{tick.to}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex whitespace-pre\">\n        {cells(tick.from, tick.to).map((cell, index) =>\n          cell.changed ? (\n            // biome-ignore lint/suspicious/noArrayIndexKey: cells are positional by design.\n            <span key={`${tick.id}-${index}`} className=\"inline-grid\">\n              <span data-tick=\"out\" className=\"[grid-area:1/1]\">\n                {cell.before}\n              </span>\n              <span data-tick=\"in\" className=\"[grid-area:1/1]\">\n                {cell.after}\n              </span>\n            </span>\n          ) : (\n            // biome-ignore lint/suspicious/noArrayIndexKey: cells are positional by design.\n            <span key={index}>{cell.after}</span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:lib",
      "target": "@lib/uai-number-tick.tsx"
    }
  ],
  "categories": [
    "utility"
  ],
  "type": "registry:lib"
}