## 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}
);
}
```