Live value that washes green or red on each tick, throttled below three flashes a second.
01Preview
WatchlistLive
- BTC-USDBitcoin
64,218.5064,218.50 - ETH-USDEther
3,121.843,121.84 - EUR-USDEuro / US dollar
1.08421.0842 - XAU-USDGold spot—
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @number-flow/react motion03Usage
import { ValueFlash } from "@/components/ui/value-flash";
<ValueFlash value={price} decimals={2} />
// Latency: falling is good. Keep a quiet arrow for the last tick.
<ValueFlash value={p95} inverse arrow="last" format={{ style: "unit", unit: "millisecond" }} />04Source
"use client";
import NumberFlow, { type Format } from "@number-flow/react";
import { useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
const noop = () => () => {};
function useLocale(locale?: string) {
const detected = useSyncExternalStore(
noop,
() => Intl.NumberFormat().resolvedOptions().locale,
() => "en-US",
);
return locale ?? detected;
}
// Three flashes a second is the ceiling for anything that blinks (WCAG 2.3.1).
const MIN_GAP = 334;
export type ThrottledValue = {
/** The value to show: the latest one, at most once per interval. */
value: number | null;
/** 1 if the last shown change went up, -1 down, 0 before any change. */
direction: 1 | -1 | 0;
/** Increments on every shown change. Key effects on it. */
tick: number;
};
/**
* Lets a firehose of updates through at most once per `interval`, always
* landing on the latest value, and reports which way each shown change went.
*/
export function useThrottledValue(value: number | null, interval = 400): ThrottledValue {
const [state, setState] = useState<ThrottledValue>({ value, direction: 0, tick: 0 });
const last = useRef(0);
const timer = useRef<number>(undefined);
const latest = useRef(value);
useEffect(() => {
latest.current = value;
if (timer.current !== undefined) return; // a trailing update is already queued and will read `latest`
const wait = Math.max(0, last.current + interval - performance.now());
timer.current = window.setTimeout(() => {
timer.current = undefined;
last.current = performance.now();
const next = latest.current;
setState((prev) => {
if (Object.is(prev.value, next)) return prev;
const direction = prev.value == null || next == null ? 0 : next > prev.value ? 1 : -1;
return { value: next, direction, tick: prev.tick + 1 };
});
}, wait);
}, [value, interval]);
useEffect(
() => () => {
window.clearTimeout(timer.current);
timer.current = undefined;
},
[],
);
return state;
}
const expo = `cubic-bezier(${ease.out.join(",")})`;
// Short: a live number that takes longer to settle than the gap between updates is always moving.
const timing = {
transformTiming: { duration: 380, easing: expo },
spinTiming: { duration: 380, easing: expo },
opacityTiming: { duration: 160, easing: "ease-out" },
};
export type ValueFlashProps = Omit<React.ComponentProps<"span">, "children"> & {
/** The live value. null shows a dash while the first one loads. */
value: number | null;
/** ISO 4217 code for prices. */
currency?: string;
/** Fixed fraction digits, so ticks never change the width: 4 for FX, 2 for most prices. */
decimals?: number;
compact?: boolean;
format?: Format;
locale?: string;
/** Minimum milliseconds between shown updates. Never below 334, so it can't flash more than three times a second. */
throttle?: number;
/** For values where down is good (latency, spread, cost). */
inverse?: boolean;
/** flash: the arrow shows with the wash and leaves. last: it stays, quiet, pointing the way of the last tick. */
arrow?: "flash" | "last" | "none";
/** The feed has stopped or fallen behind: dim, stop flashing, keep the last value. */
stale?: boolean;
size?: "sm" | "md";
};
export function ValueFlash({
value,
currency,
decimals,
compact,
format,
locale: localeProp,
throttle = 400,
inverse = false,
arrow = "flash",
stale = false,
size = "md",
className,
...rest
}: ValueFlashProps) {
const locale = useLocale(localeProp);
const reduce = useReducedMotion();
const { value: shown, direction, tick } = useThrottledValue(value, Math.max(throttle, MIN_GAP));
const wash = useRef<HTMLSpanElement>(null);
const glyph = useRef<HTMLSpanElement>(null);
// Wash and arrow ride on the Web Animations API: a new tick cancels the last
// one where it stands and restarts, with no re-render to fade them out.
useEffect(() => {
const box = wash.current;
const mark = glyph.current;
if (!tick || !direction || stale || !box) return;
const good = direction > 0 !== inverse;
const tone = good ? "success" : "danger";
const opts = { duration: 1100, easing: "ease-out", id: "value-flash" } as const;
for (const el of [box, mark]) el?.getAnimations().forEach((a) => a.id === "value-flash" && a.cancel());
box.animate(
[
{ backgroundColor: `var(--${tone}-soft)`, color: `var(--${tone})`, offset: 0 },
{ backgroundColor: `var(--${tone}-soft)`, color: `var(--${tone})`, offset: 0.4 },
],
opts,
);
if (!mark || arrow === "none") return;
const nudge = reduce ? "none" : `translateY(${direction > 0 ? 3 : -3}px)`;
mark.animate(
arrow === "flash"
? [
{ opacity: 0, transform: nudge, color: `var(--${tone})`, offset: 0 },
{ opacity: 1, transform: "none", color: `var(--${tone})`, offset: 0.14 },
{ opacity: 1, transform: "none", color: `var(--${tone})`, offset: 0.55 },
{ opacity: 0, transform: "none", offset: 1 },
]
: [
{ opacity: 0, transform: nudge, color: `var(--${tone})`, offset: 0 },
{ opacity: 1, transform: "none", color: `var(--${tone})`, offset: 0.14 },
{ color: `var(--${tone})`, offset: 0.4 },
],
opts,
);
}, [tick, direction, stale, inverse, arrow, reduce]);
const options: Format = {
...(currency ? { style: "currency", currency, currencyDisplay: "narrowSymbol" } : null),
...(compact ? { notation: "compact", maximumFractionDigits: 1 } : null),
...(decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : null),
...format,
};
const trend = direction > 0 ? "up" : direction < 0 ? "down" : "none";
return (
<span
data-slot="value-flash"
data-trend={trend}
data-stale={stale || undefined}
data-size={size}
className={cn(
"inline-flex items-center whitespace-nowrap font-medium tabular",
size === "sm" ? "gap-1.5 text-[12.5px]" : "gap-2 text-[13px]",
stale ? "text-fg-3" : "text-fg",
"transition-colors duration-300",
className,
)}
{...rest}
>
<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")}>
{shown == null ? (
<span className="text-fg-4">—</span>
) : (
<NumberFlow value={shown} locales={locale} format={options} animated={!reduce && !stale} {...timing} />
)}
</span>
{arrow !== "none" && (
// The slot is always there, so the arrow arriving never pushes anything.
<span
ref={glyph}
aria-hidden
className={cn("grid size-3 shrink-0 place-items-center text-fg-3", (arrow === "flash" || !direction || stale) && "opacity-0")}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className={cn(direction < 0 && "rotate-180")}>
<path d="M5 2 8.5 7.5h-7z" />
</svg>
</span>
)}
<span className="sr-only">{stale ? ", delayed" : trend === "up" ? ", rising" : trend === "down" ? ", falling" : ""}</span>
</span>
);
}05Props
ValueFlash
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | null | — | The live value. null shows a dash until the first one arrives. |
| decimals | number | — | Fixed fraction digits, so ticks never change the width: 2 for most prices, 4 for FX. |
| currency | string | — | ISO 4217 code, for prices shown with their symbol. |
| compact | boolean | false | Short scale: 48.2K. |
| format | Intl.NumberFormatOptions | — | Any other Intl option, applied last. |
| throttle | number | 400 | Minimum ms between shown updates. Clamped to 334 so it never flashes more than three times a second. |
| inverse | boolean | false | For values where down is good: a fall washes green. |
| arrow | "flash" | "last" | "none" | "flash" | The arrow comes and goes with the wash, stays quietly on the last direction, or is left out. |
| stale | boolean | false | The feed stopped or fell behind: dims, stops flashing and rolling, keeps the last value. |
| size | "sm" | "md" | "md" | 12.5px or 13px text. |
| locale | string | — | BCP 47 tag. Defaults to the reader's locale. |
useThrottledValue
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | null | — | The raw feed. |
| interval | number | 400 | Minimum ms between updates. Returns { value, direction, tick }, always landing on the latest value. |
06Notes
Behavior
- Updates faster than the throttle are coalesced: the first shows at once, the rest collapse into one trailing update with the latest value, so nothing is dropped and nothing strobes.
- A tick during a flash restarts it from where it is in the new direction's color; there is never a second wash stacked on the first.
- The arrow has a reserved slot and the digits are tabular with fixed decimals, so a tick never moves the column.
- Stale keeps the last value readable at the tertiary step instead of blanking it, and flashes nothing until the feed returns.
Motion
- The wash holds its tint for the first 40% of 1.1s and eases back; digits roll in 380ms, shorter than the gap between updates, so the number is readable between ticks.
- The arrow rises 3px into place (or drops, for a fall) in the first 150ms, then fades with the wash.
- Reduced motion swaps digits in place and drops the arrow's travel; the color and the arrow still arrive.
Accessibility
- The value reads as one formatted number, followed by rising, falling or delayed, so direction never depends on color.
- It is not a live region: a price that changes every second would drown a screen reader. Announce significant moves separately.
- The throttle's floor keeps flashing under three times a second, the WCAG 2.3.1 threshold.