Year in Review Stat
A headline figure counts up over two deliberate seconds, then the line that explains it arrives.
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 {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
/**
* Vibary · Year in Review Stat
*
* One number, given the whole screen and most of two seconds. The figure
* covers most of its distance early and then crawls the last stretch, so
* the eye has time to arrive at the final value rather than being handed
* it — the drama is entirely in the curve.
*
* The figure never changes size, and its box never changes size either:
* an invisible copy of the final string reserves the width, so five
* digits arriving one at a time cannot shove the layout around. Only
* once the count has settled does the line explaining it appear.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Rule and captions are mixed from the inherited text color; the accent
* is semantic and stays literal.
* Works with zero props; tune via `variant`, `value`, `unit`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type YearInReviewCountProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Small label above the figure. */
eyebrow?: string;
/** Where the count starts. */
from?: number;
/** The headline statistic. */
value?: number;
/** What the figure measures. */
unit?: string;
/** The line that arrives once the count has settled. */
context?: string;
/** Figure size in px. */
size?: number;
/** Accent colour. Semantic, so it stays literal. */
accent?: string;
/** Fires once the context line has arrived. */
onComplete?: () => void;
};
type VariantConfig = {
/** Beat before the count starts. */
delay: number;
/** How long the figure takes to arrive. */
count: number;
/** px the figure travels up as it fades in. */
rise: number;
/** Gap after the count before the context line appears. */
contextGap: number;
};
// Nothing springs and nothing scales. This is a number being read out,
// and a headline statistic that bounces reads as a scoreboard rather
// than a summary. Variants change how long the eye is given, never the
// shape of the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Brisk, for a stat sitting among several others.
subtle: { delay: 0.12, count: 1.2, rise: 6, contextGap: 0.06 },
// The all-purpose setting: slow enough to feel deliberate.
default: { delay: 0.22, count: 2.1, rise: 10, contextGap: 0.12 },
// A long read, for a full-screen card that exists only for this number.
playful: { delay: 0.3, count: 2.9, rise: 14, contextGap: 0.18 },
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const format = (n: number) => Math.round(n).toLocaleString("en-US");
export default function YearInReviewCount({
variant = "default",
eyebrow = "2026 in review",
from = 0,
value = 1284,
unit = "minutes in deep work",
context = "That is 21 hours more than last year, and your steadiest run of weeks yet.",
size = 54,
accent = "#6E56CF",
onComplete,
}: YearInReviewCountProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
const figure = useMotionValue(still ? value : from);
const digits = useTransform(figure, format);
// The context line waits for the figure, not for a stopwatch. Its
// readiness resets during render when the run itself changes.
const runKey = `${still}:${from}:${value}:${cfg.count}:${cfg.delay}`;
const fresh = { key: runKey, settled: still };
const [run, setRun] = useState(fresh);
if (run.key !== runKey) setRun(fresh);
const settled = run.key === runKey ? run.settled : fresh.settled;
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
// Reduced motion: the figure is simply the figure. Counting was only
// ever the presentation of a total that is already true.
if (still) {
figure.set(value);
return;
}
figure.set(from);
const controls = animate(figure, value, {
duration: cfg.count,
delay: cfg.delay,
// Most of the distance early, then a long crawl into place.
ease: [0.16, 1, 0.3, 1],
onComplete: () => setRun({ key: runKey, settled: true }),
});
return () => controls.stop();
}, [still, figure, from, value, cfg.count, cfg.delay, runKey]);
return (
<div style={{ width: 292, display: "flex", flexDirection: "column", gap: 10 }}>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: still ? 0.18 : 0.34, ease: "easeOut" }}
style={{
fontSize: 10.5,
fontWeight: 640,
letterSpacing: "0.11em",
textTransform: "uppercase",
color: accent,
}}
>
{eyebrow}
</motion.span>
<motion.div
initial={still ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: still ? 0.2 : 0.44,
delay: still ? 0 : cfg.delay * 0.5,
ease: [0.22, 1, 0.36, 1],
}}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
{/* An invisible copy of the final string holds the width, so the
box cannot grow as digits arrive and nothing below it moves. */}
<span
style={{
position: "relative",
display: "inline-block",
fontSize: size,
fontWeight: 680,
letterSpacing: "-0.035em",
lineHeight: 1.02,
fontVariantNumeric: "tabular-nums",
}}
>
<span aria-hidden style={{ visibility: "hidden" }}>
{format(value)}
</span>
<motion.span
style={{ position: "absolute", left: 0, top: 0, whiteSpace: "nowrap" }}
>
{digits}
</motion.span>
</span>
<span style={{ fontSize: 12.5, fontWeight: 560, color: tone(56) }}>
{unit}
</span>
</motion.div>
{/* The rule draws across on the same window as the count, so the
two finish together and the pause afterwards is unmistakable. */}
<motion.span
aria-hidden
initial={{ scaleX: still ? 1 : 0 }}
animate={{ scaleX: 1 }}
transition={{
duration: still ? 0 : cfg.count,
delay: still ? 0 : cfg.delay,
ease: [0.16, 1, 0.3, 1],
}}
style={{
height: 1,
marginTop: 4,
borderRadius: 1,
background: tone(18),
transformOrigin: "left center",
}}
/>
<div style={{ minHeight: 34 }}>
<motion.p
initial={still ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={settled ? { opacity: 1, y: 0 } : { opacity: 0, y: still ? 0 : 8 }}
transition={{
duration: still ? 0.2 : 0.42,
delay: settled && !still ? cfg.contextGap : 0,
ease: [0.22, 1, 0.36, 1],
}}
onAnimationComplete={() => {
if (settled) onCompleteRef.current?.();
}}
style={{
margin: 0,
fontSize: 11.5,
lineHeight: 1.5,
color: tone(52),
}}
>
{context}
</motion.p>
</div>
</div>
);
}About this pattern
One number, given the whole card and most of two seconds. The curve is the entire performance: the figure covers most of its distance early and then crawls the last stretch, so the eye is given time to arrive at the value rather than being handed it. Nothing springs and nothing scales — a headline statistic that bounces reads as a scoreboard instead of a summary. Two structural details do the quiet work. An invisible copy of the final string reserves the width, so digits arriving one at a time cannot shove the layout sideways. And the explanatory line waits on the figure itself rather than on a stopwatch, which is what makes the pause between them read as a held beat rather than as a gap. Reduced motion is simply the finished card, because the counting was only ever the presentation of a total that is already true.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Achievements
Annual totals presented one statistic per card, at a deliberately slow pace.
Related patterns
- Certificate IssueThe border strokes itself around the sheet, the seal settles on, and the name is written last.
- Rank PromotionThe old tier ring fades while the new tier's ring strokes itself around the badge.
- Trophy Shelf AddA new award settles into the first slot while the awards already there slide over to make room.