All patterns

Empty to Loaded Count

A stat card sitting at zero rolls up to its real figure once the data resolves.

loadingfriendlycalmautomatic · finite · intermediate · ~1.6s
Variant

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.

418 lines · react + motion only
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.

Dashboard stat cardUsage totalWorkspace homeReport header figure

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Analytics view

    Headline figures resolve from an empty shell as the query returns.

Related patterns