Cart Badge Count
The cart badge rolls each changed digit in the direction of the change and settles once.
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 · 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
Cart quantity in the header bar changes as items are added from a listing.
Related patterns
- Cart Quantity StepperStepping the quantity rolls the count and both totals in the direction of the change.
- Price Change RollA recalculated total rolls digit by digit in the direction the money moved.
- Add to Cart FlyThe product tile arcs from the card into the cart glyph, and the badge ticks over on arrival.
