{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"value-flash","type":"registry:ui","title":"Value Flash","description":"Marks what just changed.","dependencies":["motion"],"categories":["data"],"docs":"https://www.interior.dev/docs/value-flash","files":[{"path":"registry/interior/value-flash.tsx","type":"registry:ui","target":"components/interior/value-flash.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\n\nconst ROLL = { type: \"spring\", stiffness: 460, damping: 32, mass: 0.55 } as const;\n\nconst POP = { type: \"spring\", stiffness: 640, damping: 22, mass: 0.7 } as const;\n\nconst LIFT = { type: \"spring\", stiffness: 380, damping: 26, mass: 0.7 } as const;\n\nconst SETTLE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\n\nconst CLEAR = { duration: 0.16, ease: [0.4, 0, 1, 1] } as const;\nconst DROP = { duration: 0.14, ease: [0.4, 0, 1, 1] } as const;\nconst STILL = { duration: 0 } as const;\n\nconst GLYPH: Record<\"up\" | \"down\", ReactNode> = {\n  up: <path d=\"M128 68 L210 180 H46 Z\" />,\n  down: <path d=\"M128 188 L46 76 H210 Z\" />,\n};\n\nexport type FlashDirection = \"up\" | \"down\";\n\nexport type UseValueFlashOptions<T> = {\n  hold?: number;\n  compare?: (next: T, previous: T) => number;\n};\n\nexport type ValueFlashState<T> = {\n  direction: FlashDirection | null;\n  from: T;\n  changeId: number;\n  flashing: boolean;\n};\n\nexport function useValueFlash<T>(\n  value: T,\n  { hold = 900, compare }: UseValueFlashOptions<T> = {},\n): ValueFlashState<T> {\n  const [state, setState] = useState<ValueFlashState<T>>({\n    direction: null,\n    from: value,\n    changeId: 0,\n    flashing: false,\n  });\n\n  const previous = useRef(value);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const rank = useRef(compare);\n\n  useEffect(() => {\n    rank.current = compare;\n  });\n\n  useEffect(() => {\n    const prior = previous.current;\n    if (Object.is(prior, value)) return;\n    previous.current = value;\n\n    const measure = rank.current;\n    const delta = measure\n      ? measure(value, prior)\n      : typeof value === \"number\" && typeof prior === \"number\"\n        ? value - prior\n        : 0;\n\n    if (delta === 0) return;\n\n    setState((prev) => ({\n      direction: delta > 0 ? \"up\" : \"down\",\n      from: prior,\n      changeId: prev.changeId + 1,\n      flashing: true,\n    }));\n\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(() => {\n      timer.current = null;\n      setState((prev) => (prev.flashing ? { ...prev, flashing: false } : prev));\n    }, hold);\n  }, [value, hold]);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  return state;\n}\n\nexport type ValueFlashProps = {\n  value: number;\n  format?: (value: number) => string;\n  label?: string;\n  hold?: number;\n  announceAfter?: number;\n  className?: string;\n};\n\nexport function ValueFlash({\n  value,\n  format,\n  label,\n  hold = 900,\n  announceAfter = 700,\n  className = \"\",\n}: ValueFlashProps) {\n  const { direction, flashing, changeId } = useValueFlash(value, { hold });\n  const reduced = useReducedMotion();\n\n  const text = format ? format(value) : String(value);\n  const [settled, setSettled] = useState(text);\n\n  useEffect(() => {\n    const id = setTimeout(() => setSettled(text), announceAfter);\n    return () => clearTimeout(id);\n  }, [text, announceAfter]);\n\n  const tone = flashing\n    ? direction === \"up\"\n      ? \"text-emerald-600 dark:text-emerald-400\"\n      : \"text-red-600 dark:text-red-400\"\n    : \"text-stone-700 dark:text-stone-200\";\n\n  const tint =\n    direction === \"up\"\n      ? \"bg-emerald-500/[0.12] dark:bg-emerald-400/[0.14]\"\n      : \"bg-red-500/[0.12] dark:bg-red-400/[0.14]\";\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ scale: reduced ? 1 : flashing ? 1.05 : 1 }}\n      transition={reduced ? STILL : flashing ? LIFT : SETTLE}\n      className={`relative inline-grid grid-flow-col items-center gap-1.5 rounded-[6px] px-1.5 py-[3px] text-[13px] font-medium tabular-nums transition-colors duration-200 ${tone} ${className}`}\n    >\n      {direction ? (\n        <motion.span\n          aria-hidden\n          initial={{ opacity: 0 }}\n          animate={{ opacity: flashing ? 1 : 0 }}\n          transition={reduced ? STILL : flashing ? CELL : CLEAR}\n          className={`pointer-events-none absolute inset-0 rounded-[6px] ${tint}`}\n        />\n      ) : null}\n\n      <span aria-hidden className=\"relative inline-grid overflow-hidden\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={changeId}\n            initial={\n              reduced\n                ? { opacity: 0 }\n                : {\n                    opacity: 0,\n                    y: direction === \"down\" ? \"-0.85em\" : \"0.85em\",\n                    filter: \"blur(5px)\",\n                  }\n            }\n            animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n            exit={\n              reduced\n                ? { opacity: 0, transition: STILL }\n                : {\n                    opacity: 0,\n                    y: direction === \"down\" ? \"0.7em\" : \"-0.7em\",\n                    filter: \"blur(4px)\",\n                    transition: DROP,\n                  }\n            }\n            transition={reduced ? STILL : ROLL}\n            className=\"col-start-1 row-start-1\"\n          >\n            {text}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <span aria-hidden className=\"relative grid size-[1em] place-items-center\">\n        <AnimatePresence initial={false}>\n          {flashing && direction ? (\n            <motion.svg\n              key={`${changeId}-${direction}`}\n              viewBox=\"0 0 256 256\"\n              fill=\"currentColor\"\n              initial={\n                reduced\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      scale: 0.4,\n                      y: direction === \"up\" ? \"0.3em\" : \"-0.3em\",\n                    }\n              }\n              animate={{ opacity: 1, scale: 1, y: \"0em\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: STILL }\n                  : { opacity: 0, scale: 0.8, transition: CLEAR }\n              }\n              transition={reduced ? STILL : POP}\n              className=\"col-start-1 row-start-1 block h-[0.68em] w-[0.68em]\"\n            >\n              {GLYPH[direction]}\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {label ? `${label}: ${settled}` : settled}\n      </span>\n    </motion.span>\n  );\n}\n"}]}