## Value Flash — Data Marks what just changed. Docs: https://www.interior.dev/docs/value-flash Reference: https://www.interior.dev/reference/value-flash License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/value-flash.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { ValueFlash, useValueFlash } from "@/components/interior/value-flash"; type Quote = { symbol: string; last: number }; const usd = (n: number) => n.toLocaleString("en-US", { style: "currency", currency: "USD" }); export function QuoteRow({ quote }: { quote: Quote }) { const { flashing } = useValueFlash(quote.last, { hold: 1200 }); return (
{quote.symbol}
); } ``` ### Props - `value` (`number`) The number to watch. A change of identity, not of render, is what marks it. - `format` (`(value: number) => string`) — default: `String` Formats the displayed text. Rendered with tabular-nums so equal-length values never jitter. - `label` (`string`) Prefixes the live-region announcement, so a screen reader hears which figure moved. - `hold` (`number`) — default: `900` Milliseconds the tint and direction mark stay lit before clearing themselves. - `announceAfter` (`number`) — default: `700` Quiet period before the settled value is announced. Each new change restarts it. - `className` (`string`) — default: `""` Appended last, so callers can override type size, weight and colour. ### Behavior notes - Nothing flashes on first paint: the previous value is seeded at mount, so a hydrated table does not light every row before the user has done anything. - A re-render that leaves the value identical is ignored under Object.is, and a change whose delta is zero is ignored too — there is no third, directionless flash, because a blink with no direction is the one nobody can read. - Direction speaks three ways at once — moss or flag tint for the glance, a solid triangle in a permanently reserved cell for greyscale and colour blindness, and the new number rolling in from the side it moved — so the row never reflows and the change is legible at any speed. - The tint is an absolutely positioned overlay fading its opacity on a spring, never a width, height or mask animation, and it decays once instead of looping so a stale figure can never keep looking fresh. - Screen readers get one announcement per settled value: each tick restarts a quiet period, so a fast feed does not read sixty numbers a second into the live region. - Every timer is cleared on the next change and on unmount, so a row removed mid-flash leaves nothing scheduled and no state set on a dead component. ### Source (`components/interior/value-flash.tsx`) ```tsx "use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const ROLL = { type: "spring", stiffness: 460, damping: 32, mass: 0.55 } as const; const POP = { type: "spring", stiffness: 640, damping: 22, mass: 0.7 } as const; const LIFT = { type: "spring", stiffness: 380, damping: 26, mass: 0.7 } as const; const SETTLE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const CLEAR = { duration: 0.16, ease: [0.4, 0, 1, 1] } as const; const DROP = { duration: 0.14, ease: [0.4, 0, 1, 1] } as const; const STILL = { duration: 0 } as const; const GLYPH: Record<"up" | "down", ReactNode> = { up: , down: , }; export type FlashDirection = "up" | "down"; export type UseValueFlashOptions = { hold?: number; compare?: (next: T, previous: T) => number; }; export type ValueFlashState = { direction: FlashDirection | null; from: T; changeId: number; flashing: boolean; }; export function useValueFlash( value: T, { hold = 900, compare }: UseValueFlashOptions = {}, ): ValueFlashState { const [state, setState] = useState>({ direction: null, from: value, changeId: 0, flashing: false, }); const previous = useRef(value); const timer = useRef | null>(null); const rank = useRef(compare); useEffect(() => { rank.current = compare; }); useEffect(() => { const prior = previous.current; if (Object.is(prior, value)) return; previous.current = value; const measure = rank.current; const delta = measure ? measure(value, prior) : typeof value === "number" && typeof prior === "number" ? value - prior : 0; if (delta === 0) return; setState((prev) => ({ direction: delta > 0 ? "up" : "down", from: prior, changeId: prev.changeId + 1, flashing: true, })); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => { timer.current = null; setState((prev) => (prev.flashing ? { ...prev, flashing: false } : prev)); }, hold); }, [value, hold]); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); return state; } export type ValueFlashProps = { value: number; format?: (value: number) => string; label?: string; hold?: number; announceAfter?: number; className?: string; }; export function ValueFlash({ value, format, label, hold = 900, announceAfter = 700, className = "", }: ValueFlashProps) { const { direction, flashing, changeId } = useValueFlash(value, { hold }); const reduced = useReducedMotion(); const text = format ? format(value) : String(value); const [settled, setSettled] = useState(text); useEffect(() => { const id = setTimeout(() => setSettled(text), announceAfter); return () => clearTimeout(id); }, [text, announceAfter]); const tone = flashing ? direction === "up" ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400" : "text-stone-700 dark:text-stone-200"; const tint = direction === "up" ? "bg-emerald-500/[0.12] dark:bg-emerald-400/[0.14]" : "bg-red-500/[0.12] dark:bg-red-400/[0.14]"; return ( {direction ? ( ) : null} {text} {flashing && direction ? ( {GLYPH[direction]} ) : null} {label ? `${label}: ${settled}` : settled} ); } ```