Empty to Loaded Count
A stat card sitting at zero rolls up to its real figure once the data resolves.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Empty to Loaded Count
*
* A stat that starts at zero because nothing has loaded yet, and rolls up
* to the real figure when it does. Each digit column moves by
* translation and a crossfade — the type never scales, so the number
* stays legible the whole way up — and the final width is reserved from
* the first frame, so the card cannot resize as digits are added.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `value`, `delayMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type EmptyToLoadedCountProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The figure the count resolves to. */
value?: number;
/** Label above the figure. */
label?: string;
/** Caption while the card is still empty. */
emptyCaption?: string;
/** Caption once the data has landed. */
loadedCaption?: string;
/** Movement chip revealed with the loaded caption. */
delta?: string;
/** Beat before the roll starts, in ms. */
delayMs?: number;
/** Accent for the delta chip. */
accent?: string;
/** Fires once the figure has settled. */
onSettled?: () => void;
};
type VariantConfig = {
/** How many intermediate figures the roll passes through. */
steps: number;
/** Total length of the roll, in ms. */
rollMs: number;
/** Crossfade for one digit handing over to the next. */
fadeSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Digits are read the instant they stop, so the column spring never
// overshoots twice: damping ratios (ζ = damping / 2√stiffness) sit at or
// above 0.94. Variants change how many figures the roll passes through
// and how long it takes, never how much it wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00, five figures — almost a cut. For a dense grid of stats.
subtle: {
steps: 5,
rollMs: 620,
fadeSeconds: 0.1,
spring: { type: "spring", stiffness: 620, damping: 50 },
},
// ζ ≈ 0.98. The all-purpose setting.
default: {
steps: 9,
rollMs: 980,
fadeSeconds: 0.13,
spring: { type: "spring", stiffness: 480, damping: 43 },
},
// ζ ≈ 0.94, a longer climb — for a single hero figure.
playful: {
steps: 13,
rollMs: 1320,
fadeSeconds: 0.16,
spring: { type: "spring", stiffness: 380, damping: 37 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ACCENT = "#10B981";
const DIGIT_HEIGHT = 38;
const format = (value: number) => value.toLocaleString("en-US");
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
export default function EmptyToLoadedCount({
variant = "default",
value = 1482,
label = "Documents indexed",
emptyCaption = "Nothing indexed yet",
loadedCaption = "Across 6 connected sources",
delta = "+128 this week",
delayMs = 650,
accent = ACCENT,
onSettled,
}: EmptyToLoadedCountProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [current, setCurrent] = useState(0);
const [loaded, setLoaded] = useState(false);
const onSettledRef = useRef(onSettled);
useEffect(() => {
onSettledRef.current = onSettled;
}, [onSettled]);
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
const finish = () => {
setCurrent(value);
setLoaded(true);
onSettledRef.current?.();
};
if (reduceMotion) {
// The figure is the information; the climb was only the delivery.
// Reduced motion keeps the arrival and drops the climb entirely.
timers.push(setTimeout(finish, delayMs));
} else {
for (let step = 1; step <= cfg.steps; step++) {
const at = delayMs + (cfg.rollMs * step) / cfg.steps;
// Decelerating: the count covers most of its ground early, so the
// last few figures are slow enough to read.
const figure = Math.round(value * easeOutCubic(step / cfg.steps));
timers.push(
setTimeout(
step === cfg.steps ? finish : () => setCurrent(figure),
at
)
);
}
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [value, delayMs, cfg, reduceMotion]);
const target = format(value);
const shown = format(current);
return (
<div
style={{
// The designed width is a floor, not a cap. The caption line is
// taken out of flow below so the card's height is settled early,
// which also means it cannot push the card wider when its copy
// is long — and `loadedCaption` and `delta` are props, so their
// length is the caller's. Sizing to content keeps an unfamiliar
// caption inside the card instead of hanging past its edge.
width: "fit-content",
minWidth: 244,
padding: "14px 16px 15px",
borderRadius: 14,
background: tone(5),
border: `1px solid ${tone(10)}`,
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
opacity: 0.5,
}}
>
{label}
</div>
<div style={{ marginTop: 8 }}>
<Odometer
shown={shown}
target={target}
cfg={cfg}
still={Boolean(reduceMotion)}
/>
</div>
{/* Caption and chip share one reserved line, so the card height is
settled before either of them has anything to say — and a
hidden copy of the loaded state reserves its width, so the card
is already wide enough for the caption it will end on. Same
trick the odometer uses for the digits, for the same reason:
nothing about this card may resize once it is on screen. */}
<div
style={{
position: "relative",
height: 17,
marginTop: 6,
fontSize: 11.5,
}}
>
<div
aria-hidden
style={{
visibility: "hidden",
display: "flex",
alignItems: "center",
gap: 7,
lineHeight: "17px",
whiteSpace: "nowrap",
}}
>
<LoadedCaption delta={delta} caption={loadedCaption} accent={accent} />
</div>
<AnimatePresence initial={false}>
<motion.div
key={loaded ? "loaded" : "empty"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
gap: 7,
lineHeight: "17px",
whiteSpace: "nowrap",
}}
>
{loaded ? (
<LoadedCaption delta={delta} caption={loadedCaption} accent={accent} />
) : (
<span style={{ opacity: 0.45 }}>{emptyCaption}</span>
)}
</motion.div>
</AnimatePresence>
</div>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{loaded ? `${label}: ${target}` : emptyCaption}
</span>
</div>
);
}
/** The settled caption: the movement chip, then the qualifier. Rendered
* twice — once hidden to reserve the line's width, once for real. */
function LoadedCaption({
delta,
caption,
accent,
}: {
delta: string;
caption: string;
accent: string;
}) {
return (
<>
<span
style={{
padding: "1px 6px",
borderRadius: 999,
fontSize: 10.5,
fontWeight: 650,
color: accent,
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
}}
>
{delta}
</span>
<span style={{ opacity: 0.5 }}>{caption}</span>
</>
);
}
/**
* The figure. A hidden copy of the final string reserves the width, and
* the live digits sit over it, left aligned — so the count can grow from
* one digit to four without the card resizing or the type re-centering.
*/
function Odometer({
shown,
target,
cfg,
still,
}: {
shown: string;
target: string;
cfg: VariantConfig;
still: boolean;
}) {
const chars = shown.split("");
const travel = still ? 0 : DIGIT_HEIGHT;
return (
<span
// Silent to assistive tech: every intermediate figure would be
// announced otherwise. The settled figure is read out once, from
// the live region beside the card.
aria-hidden
style={{
position: "relative",
display: "inline-block",
fontSize: 30,
fontWeight: 650,
letterSpacing: -0.6,
lineHeight: `${DIGIT_HEIGHT}px`,
fontVariantNumeric: "tabular-nums",
}}
>
<span aria-hidden style={{ visibility: "hidden" }}>
{target}
</span>
<span
aria-hidden
style={{
position: "absolute",
left: 0,
top: 0,
display: "inline-flex",
alignItems: "flex-start",
}}
>
{chars.map((char, index) => {
// Columns are identified from the right, so the units column
// stays the units column as the figure gains digits — a new
// digit joins on the left instead of shunting the others along.
const column = chars.length - 1 - index;
if (!/[0-9]/.test(char)) {
return (
<span key={`sep-${column}`} style={{ display: "inline-block" }}>
{char}
</span>
);
}
return (
<DigitColumn
key={`col-${column}`}
char={char}
cfg={cfg}
travel={travel}
/>
);
})}
</span>
</span>
);
}
/**
* One column of the figure.
*
* The presence key carries a generation as well as the digit, and that
* is the whole point of this component. Counting to 1,482 puts 4, 7, 0,
* 2, 3, 4 through the hundreds column: the 4 comes back while the first
* 4 is still in the exit list, and a key that is only the digit collides
* with the copy already leaving. The returning digit then mounts and
* never plays its entrance — it stays at the initial offset, fully
* transparent, and the column reads blank for good. That is how 1,482
* settled on screen as "1, 82". A generation makes every hand-over a
* distinct key, so a repeat is an ordinary crossfade.
*/
function DigitColumn({
char,
cfg,
travel,
}: {
char: string;
cfg: VariantConfig;
travel: number;
}) {
// Derived during render rather than in an effect: the key has to be
// right in the same commit that shows the new digit, and an effect
// would run a frame late — long enough to start the wrong animation.
const [seen, setSeen] = useState(char);
const [generation, setGeneration] = useState(0);
if (seen !== char) {
setSeen(char);
setGeneration((value) => value + 1);
}
return (
<span
style={{
display: "inline-grid",
height: DIGIT_HEIGHT,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={`${char}-${generation}`}
// Counting up, so the incoming digit rises from below and the
// outgoing one leaves through the top.
initial={{ y: travel, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -travel, opacity: 0 }}
transition={{
y: cfg.spring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{ gridArea: "1 / 1", display: "block" }}
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
}About this pattern
The moment a dashboard renders its shell before the numbers exist. The card shows a genuine zero and an honest caption rather than a placeholder block, then climbs to the real figure on a decelerating curve so the last few values are slow enough to read. Each digit column moves by translation with a short crossfade and holds one type size throughout — a figure that scales while it counts is a figure you have to re-read. A hidden copy of the final string reserves the width from the first frame, so a count growing from one digit to four never resizes the card or re-centres the type, and only the settled figure is announced to assistive tech.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Analytics view
Headline figures resolve from an empty shell as the query returns.
Related patterns
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
- Streak AdvanceThe connector reaches today's cell, the cell fills, and the streak count rolls over.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.