Data·08.3
Value Flash
Marks what just changed.
Requests per second: 1,284
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"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 (
<div
className={`flex items-center justify-between rounded-[9px] border px-3 py-2 ${
flashing
? "border-stone-300 dark:border-white/20"
: "border-transparent"
}`}
>
<span className="text-[13px] text-stone-700 dark:text-stone-200">
{quote.symbol}
</span>
<ValueFlash
value={quote.last}
label={`${quote.symbol} last traded`}
format={usd}
hold={1200}
/>
</div>
);
}Source
"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: <path d="M128 68 L210 180 H46 Z" />,
down: <path d="M128 188 L46 76 H210 Z" />,
};
export type FlashDirection = "up" | "down";
export type UseValueFlashOptions<T> = {
hold?: number;
compare?: (next: T, previous: T) => number;
};
export type ValueFlashState<T> = {
direction: FlashDirection | null;
from: T;
changeId: number;
flashing: boolean;
};
export function useValueFlash<T>(
value: T,
{ hold = 900, compare }: UseValueFlashOptions<T> = {},
): ValueFlashState<T> {
const [state, setState] = useState<ValueFlashState<T>>({
direction: null,
from: value,
changeId: 0,
flashing: false,
});
const previous = useRef(value);
const timer = useRef<ReturnType<typeof setTimeout> | 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 (
<motion.span
initial={false}
animate={{ scale: reduced ? 1 : flashing ? 1.05 : 1 }}
transition={reduced ? STILL : flashing ? LIFT : SETTLE}
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}`}
>
{direction ? (
<motion.span
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: flashing ? 1 : 0 }}
transition={reduced ? STILL : flashing ? CELL : CLEAR}
className={`pointer-events-none absolute inset-0 rounded-[6px] ${tint}`}
/>
) : null}
<span aria-hidden className="relative inline-grid overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={changeId}
initial={
reduced
? { opacity: 0 }
: {
opacity: 0,
y: direction === "down" ? "-0.85em" : "0.85em",
filter: "blur(5px)",
}
}
animate={{ opacity: 1, y: "0em", filter: "blur(0px)" }}
exit={
reduced
? { opacity: 0, transition: STILL }
: {
opacity: 0,
y: direction === "down" ? "0.7em" : "-0.7em",
filter: "blur(4px)",
transition: DROP,
}
}
transition={reduced ? STILL : ROLL}
className="col-start-1 row-start-1"
>
{text}
</motion.span>
</AnimatePresence>
</span>
<span aria-hidden className="relative grid size-[1em] place-items-center">
<AnimatePresence initial={false}>
{flashing && direction ? (
<motion.svg
key={`${changeId}-${direction}`}
viewBox="0 0 256 256"
fill="currentColor"
initial={
reduced
? { opacity: 0 }
: {
opacity: 0,
scale: 0.4,
y: direction === "up" ? "0.3em" : "-0.3em",
}
}
animate={{ opacity: 1, scale: 1, y: "0em" }}
exit={
reduced
? { opacity: 0, transition: STILL }
: { opacity: 0, scale: 0.8, transition: CLEAR }
}
transition={reduced ? STILL : POP}
className="col-start-1 row-start-1 block h-[0.68em] w-[0.68em]"
>
{GLYPH[direction]}
</motion.svg>
) : null}
</AnimatePresence>
</span>
<span className="sr-only" aria-live="polite">
{label ? `${label}: ${settled}` : settled}
</span>
</motion.span>
);
}Props
valuenumberThe number to watch. A change of identity, not of render, is what marks it.
formatString(value: number) => stringFormats the displayed text. Rendered with tabular-nums so equal-length values never jitter.
labelstringPrefixes the live-region announcement, so a screen reader hears which figure moved.
hold900numberMilliseconds the tint and direction mark stay lit before clearing themselves.
announceAfter700numberQuiet period before the settled value is announced. Each new change restarts it.
className""stringAppended last, so callers can override type size, weight and colour.