All patterns

Cart Badge Count

The cart badge rolls each changed digit in the direction of the change and settles once.

commercefriendlyminimalautomatic · finite · intermediate · ~3.8s
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.

275 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Cart Badge Count
 *
 * The badge rolls to its new number: each digit that actually changed
 * travels in the direction of the change and crossfades, the digits that
 * did not change hold perfectly still, and the pill widens when a second
 * digit arrives.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Chrome is mixed from the inherited text color, so the bar reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `counts`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CartBadgeCountProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Counts to walk through, starting at the first. */
  counts?: number[];
  /** Beat between one count and the following one, in ms. */
  stepMs?: number;
  /** Accent for the badge pill. */
  accent?: string;
  /** Wordmark shown at the left of the bar. */
  storeName?: string;
  /** Fires each time the badge lands on a new number. */
  onChange?: (count: number) => void;
};

type VariantConfig = {
  /** Spring the rolling digit rides into place. */
  roll: { type: "spring"; stiffness: number; damping: number };
  /** Crossfade length for the outgoing and incoming digit. */
  fadeSeconds: number;
  /** How long the pill takes to widen for an extra digit. */
  widenSeconds: number;
};

// A number in mid-change is unreadable if it bounces, so every spring
// here sits at or above a 0.87 damping ratio (damping / 2√stiffness) and
// the digit is legible the moment it stops. Variants differ in speed,
// never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a cut. For a header badge that updates all day.
  subtle: {
    roll: { type: "spring", stiffness: 750, damping: 53 },
    fadeSeconds: 0.1,
    widenSeconds: 0.13,
  },
  // One soft settle at the end of the roll. All-purpose.
  default: {
    roll: { type: "spring", stiffness: 440, damping: 38 },
    fadeSeconds: 0.16,
    widenSeconds: 0.22,
  },
  // A longer roll, so the change is visible from across a wide page.
  playful: {
    roll: { type: "spring", stiffness: 240, damping: 28 },
    fadeSeconds: 0.22,
    widenSeconds: 0.28,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The badge accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const LINE = 14;
const DIGIT_WIDTH = 7;

const DEFAULT_COUNTS = [1, 2, 3, 12, 11];

/** One digit slot. Keyed by its own character, so a position whose digit
 *  is unchanged never re-mounts and therefore never moves. */
function DigitSlot({
  char,
  direction,
  cfg,
  still,
}: {
  char: string;
  direction: 1 | -1;
  cfg: VariantConfig;
  still: boolean;
}) {
  const travel = still ? 0 : direction * LINE;
  return (
    <span
      style={{
        display: "grid",
        width: DIGIT_WIDTH,
        height: LINE,
        overflow: "hidden",
      }}
    >
      <AnimatePresence initial={false}>
        <motion.span
          key={char}
          initial={{ y: travel, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: -travel, opacity: 0 }}
          transition={{
            y: still ? { duration: 0 } : cfg.roll,
            opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
          }}
          style={{
            gridArea: "1 / 1",
            display: "block",
            textAlign: "center",
            fontSize: 11,
            fontWeight: 700,
            lineHeight: `${LINE}px`,
            color: "#FFFFFF",
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export default function CartBadgeCount({
  variant = "default",
  counts = DEFAULT_COUNTS,
  stepMs = 950,
  accent = "#7C7CF0",
  storeName = "Northwind Supply",
  onChange,
}: CartBadgeCountProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [step, setStep] = useState(0);
  const count = counts[Math.min(step, counts.length - 1)] ?? 0;
  const previous = counts[Math.max(step - 1, 0)] ?? count;
  // Counting up rolls upward, counting down rolls downward — the
  // direction is the only cue that says which way the total moved.
  const direction: 1 | -1 = count >= previous ? 1 : -1;

  const onChangeRef = useRef(onChange);
  useEffect(() => {
    onChangeRef.current = onChange;
  }, [onChange]);

  useEffect(() => {
    if (step >= counts.length - 1) return;
    const timer = setTimeout(() => setStep((current) => current + 1), stepMs);
    return () => clearTimeout(timer);
  }, [step, counts.length, stepMs]);

  useEffect(() => {
    if (step > 0) onChangeRef.current?.(counts[step]);
  }, [step, counts]);

  const digits = String(count).split("");
  const previousDigits = String(previous).split("");
  // Right-aligned comparison: in 9 → 10 the units column really did
  // change from 9 to 0, and the new tens column is a fresh slot.
  const unchangedAt = (index: number) =>
    previousDigits[index - (digits.length - previousDigits.length)] ===
    digits[index];

  const pillWidth = 12 + digits.length * DIGIT_WIDTH;

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        gap: 14,
        width: 260,
        padding: "10px 12px",
        borderRadius: 12,
        background: tone(5),
        border: `1px solid ${tone(10)}`,
        fontSize: 13,
      }}
    >
      <span style={{ fontSize: 12.5, fontWeight: 600, letterSpacing: 0.1 }}>
        {storeName}
      </span>

      <span
        role="status"
        aria-live="polite"
        style={{
          position: "relative",
          marginLeft: "auto",
          width: 34,
          height: 34,
          display: "grid",
          placeItems: "center",
          borderRadius: 10,
          background: tone(6),
        }}
      >
        <svg width="17" height="17" viewBox="0 0 20 20" fill="none" aria-hidden>
          <path
            d="M2.6 3.2h2.1l2 9.6h8.5l1.8-6.9H6.1"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
          <circle cx="8.4" cy="16.1" r="1.3" fill="currentColor" />
          <circle cx="14.4" cy="16.1" r="1.3" fill="currentColor" />
        </svg>

        {/* The pill is decoration for a screen reader; the sentence below
            is what actually gets announced when the total moves. */}
        <span
          style={{
            position: "absolute",
            width: 1,
            height: 1,
            overflow: "hidden",
            clipPath: "inset(50%)",
            whiteSpace: "nowrap",
          }}
        >
          {`Cart, ${count} item${count === 1 ? "" : "s"}`}
        </span>

        {/* The pill widens for a second digit. That is a real size change,
            so it tweens on width — short and eased — while the digits
            inside keep a constant type size throughout. */}
        <motion.span
          aria-hidden
          initial={{ scale: reduceMotion ? 1 : 0.5, opacity: 0, width: pillWidth }}
          animate={{ scale: 1, opacity: 1, width: pillWidth }}
          transition={{
            scale: reduceMotion ? { duration: 0 } : cfg.roll,
            opacity: { duration: 0.16, ease: "easeOut" },
            width: {
              duration: reduceMotion ? 0 : cfg.widenSeconds,
              ease: "easeOut",
            },
          }}
          style={{
            position: "absolute",
            top: -6,
            right: -8,
            height: 18,
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
            borderRadius: 999,
            background: accent,
            overflow: "hidden",
          }}
        >
          {digits.map((char, index) => (
            <DigitSlot
              // Position-stable key: the slot persists, its content rolls.
              key={`slot-${digits.length}-${index}`}
              char={char}
              direction={direction}
              cfg={cfg}
              // Reduced motion keeps the number, drops the travel.
              still={reduceMotion || unchangedAt(index)}
            />
          ))}
        </motion.span>
      </span>
    </div>
  );
}

About this pattern

A running total that reads cleanly while it moves. Only the digits that actually changed travel — the rest hold dead still — and they roll upward when the total climbs, downward when it falls, so the direction is legible before the number is. The pill widens on a short eased tween when a second digit arrives. Nothing scales: a number that grows and shrinks mid-change is unreadable, which is precisely the moment a shopper is trying to read it.

Cart badge in the header barUnread counter on a bag glyphSaved-items counterQuantity total in a store nav

Where it shows up

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

  • StoreDevicesAudioAccessoriesSupport
    NewAster Studio 14From $1,299
    Finish — Slate
    Buy
    Product page

    Cart quantity in the header bar changes as items are added from a listing.

Related patterns