{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "value-flash",
  "type": "registry:ui",
  "title": "Value Flash",
  "description": "Live value that washes green or red on each tick, throttled below three flashes a second.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/value-flash.tsx",
      "content": "\"use client\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.NumberFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\n// Three flashes a second is the ceiling for anything that blinks (WCAG 2.3.1).\nconst MIN_GAP = 334;\n\nexport type ThrottledValue = {\n  /** The value to show: the latest one, at most once per interval. */\n  value: number | null;\n  /** 1 if the last shown change went up, -1 down, 0 before any change. */\n  direction: 1 | -1 | 0;\n  /** Increments on every shown change. Key effects on it. */\n  tick: number;\n};\n\n/**\n * Lets a firehose of updates through at most once per `interval`, always\n * landing on the latest value, and reports which way each shown change went.\n */\nexport function useThrottledValue(value: number | null, interval = 400): ThrottledValue {\n  const [state, setState] = useState<ThrottledValue>({ value, direction: 0, tick: 0 });\n  const last = useRef(0);\n  const timer = useRef<number>(undefined);\n  const latest = useRef(value);\n\n  useEffect(() => {\n    latest.current = value;\n    if (timer.current !== undefined) return; // a trailing update is already queued and will read `latest`\n    const wait = Math.max(0, last.current + interval - performance.now());\n    timer.current = window.setTimeout(() => {\n      timer.current = undefined;\n      last.current = performance.now();\n      const next = latest.current;\n      setState((prev) => {\n        if (Object.is(prev.value, next)) return prev;\n        const direction = prev.value == null || next == null ? 0 : next > prev.value ? 1 : -1;\n        return { value: next, direction, tick: prev.tick + 1 };\n      });\n    }, wait);\n  }, [value, interval]);\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(timer.current);\n      timer.current = undefined;\n    },\n    [],\n  );\n  return state;\n}\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\n// Short: a live number that takes longer to settle than the gap between updates is always moving.\nconst timing = {\n  transformTiming: { duration: 380, easing: expo },\n  spinTiming: { duration: 380, easing: expo },\n  opacityTiming: { duration: 160, easing: \"ease-out\" },\n};\n\nexport type ValueFlashProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The live value. null shows a dash while the first one loads. */\n  value: number | null;\n  /** ISO 4217 code for prices. */\n  currency?: string;\n  /** Fixed fraction digits, so ticks never change the width: 4 for FX, 2 for most prices. */\n  decimals?: number;\n  compact?: boolean;\n  format?: Format;\n  locale?: string;\n  /** Minimum milliseconds between shown updates. Never below 334, so it can't flash more than three times a second. */\n  throttle?: number;\n  /** For values where down is good (latency, spread, cost). */\n  inverse?: boolean;\n  /** flash: the arrow shows with the wash and leaves. last: it stays, quiet, pointing the way of the last tick. */\n  arrow?: \"flash\" | \"last\" | \"none\";\n  /** The feed has stopped or fallen behind: dim, stop flashing, keep the last value. */\n  stale?: boolean;\n  size?: \"sm\" | \"md\";\n};\n\nexport function ValueFlash({\n  value,\n  currency,\n  decimals,\n  compact,\n  format,\n  locale: localeProp,\n  throttle = 400,\n  inverse = false,\n  arrow = \"flash\",\n  stale = false,\n  size = \"md\",\n  className,\n  ...rest\n}: ValueFlashProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const { value: shown, direction, tick } = useThrottledValue(value, Math.max(throttle, MIN_GAP));\n  const wash = useRef<HTMLSpanElement>(null);\n  const glyph = useRef<HTMLSpanElement>(null);\n\n  // Wash and arrow ride on the Web Animations API: a new tick cancels the last\n  // one where it stands and restarts, with no re-render to fade them out.\n  useEffect(() => {\n    const box = wash.current;\n    const mark = glyph.current;\n    if (!tick || !direction || stale || !box) return;\n    const good = direction > 0 !== inverse;\n    const tone = good ? \"success\" : \"danger\";\n    const opts = { duration: 1100, easing: \"ease-out\", id: \"value-flash\" } as const;\n    for (const el of [box, mark]) el?.getAnimations().forEach((a) => a.id === \"value-flash\" && a.cancel());\n    box.animate(\n      [\n        { backgroundColor: `var(--${tone}-soft)`, color: `var(--${tone})`, offset: 0 },\n        { backgroundColor: `var(--${tone}-soft)`, color: `var(--${tone})`, offset: 0.4 },\n      ],\n      opts,\n    );\n    if (!mark || arrow === \"none\") return;\n    const nudge = reduce ? \"none\" : `translateY(${direction > 0 ? 3 : -3}px)`;\n    mark.animate(\n      arrow === \"flash\"\n        ? [\n            { opacity: 0, transform: nudge, color: `var(--${tone})`, offset: 0 },\n            { opacity: 1, transform: \"none\", color: `var(--${tone})`, offset: 0.14 },\n            { opacity: 1, transform: \"none\", color: `var(--${tone})`, offset: 0.55 },\n            { opacity: 0, transform: \"none\", offset: 1 },\n          ]\n        : [\n            { opacity: 0, transform: nudge, color: `var(--${tone})`, offset: 0 },\n            { opacity: 1, transform: \"none\", color: `var(--${tone})`, offset: 0.14 },\n            { color: `var(--${tone})`, offset: 0.4 },\n          ],\n      opts,\n    );\n  }, [tick, direction, stale, inverse, arrow, reduce]);\n\n  const options: Format = {\n    ...(currency ? { style: \"currency\", currency, currencyDisplay: \"narrowSymbol\" } : null),\n    ...(compact ? { notation: \"compact\", maximumFractionDigits: 1 } : null),\n    ...(decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : null),\n    ...format,\n  };\n  const trend = direction > 0 ? \"up\" : direction < 0 ? \"down\" : \"none\";\n\n  return (\n    <span\n      data-slot=\"value-flash\"\n      data-trend={trend}\n      data-stale={stale || undefined}\n      data-size={size}\n      className={cn(\n        \"inline-flex items-center whitespace-nowrap font-medium tabular\",\n        size === \"sm\" ? \"gap-1.5 text-[12.5px]\" : \"gap-2 text-[13px]\",\n        stale ? \"text-fg-3\" : \"text-fg\",\n        \"transition-colors duration-300\",\n        className,\n      )}\n      {...rest}\n    >\n      <span ref={wash} className={cn(\"inline-flex items-center rounded-[5px]\", size === \"sm\" ? \"-mx-1 h-5 px-1\" : \"-mx-1.5 h-6 px-1.5\")}>\n        {shown == null ? (\n          <span className=\"text-fg-4\">—</span>\n        ) : (\n          <NumberFlow value={shown} locales={locale} format={options} animated={!reduce && !stale} {...timing} />\n        )}\n      </span>\n      {arrow !== \"none\" && (\n        // The slot is always there, so the arrow arriving never pushes anything.\n        <span\n          ref={glyph}\n          aria-hidden\n          className={cn(\"grid size-3 shrink-0 place-items-center text-fg-3\", (arrow === \"flash\" || !direction || stale) && \"opacity-0\")}\n        >\n          <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"currentColor\" className={cn(direction < 0 && \"rotate-180\")}>\n            <path d=\"M5 2 8.5 7.5h-7z\" />\n          </svg>\n        </span>\n      )}\n      <span className=\"sr-only\">{stale ? \", delayed\" : trend === \"up\" ? \", rising\" : trend === \"down\" ? \", falling\" : \"\"}</span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/value-flash"
}
