Cart Quantity Stepper
Stepping the quantity rolls the count and both totals in the direction of the change.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Cart Quantity Stepper
*
* Change the quantity and every figure that depends on it rolls in the
* direction of the change: up for more, down for less. The digits
* translate and crossfade inside fixed-width slots — they never scale,
* never bounce and never shift the layout, because the one thing a
* shopper must be able to trust at a glance is the number.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color; the thumbnail is a
* CSS gradient standing in for photography until you pass `imageSrc`.
* Works with zero props; tune via `variant`, `unitPrice`, `imageSrc`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type QuantityStepperCartProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Price of a single unit. */
unitPrice?: number;
/** Quantity on first render. */
defaultQuantity?: number;
/** What the rest of the cart comes to. */
otherItemsTotal?: number;
/** Real product photograph; omit for the built-in gradient placeholder. */
imageSrc?: string;
/** Fires with the new quantity. */
onQuantityChange?: (quantity: number) => void;
};
type VariantConfig = {
/** How far a rolling figure travels, in pixels. */
travel: number;
/** Seconds for a figure to roll. */
roll: number;
press: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: money and counts translate, they never scale. The only
// sprung element is the button press, at a damping ratio at or above
// 0.8, and every figure sits in a fixed-width tabular slot so a 9
// becoming a 10 cannot nudge the row sideways.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a roll — for a cart line inside a long list.
subtle: {
travel: 8,
roll: 0.16,
press: { type: "spring", stiffness: 620, damping: 44 },
},
// The direction of the change is legible. All-purpose.
default: {
travel: 15,
roll: 0.24,
press: { type: "spring", stiffness: 480, damping: 38 },
},
// A longer roll for a cart page where the total is the headline.
playful: {
travel: 22,
roll: 0.32,
press: { type: "spring", stiffness: 400, damping: 34 },
},
};
const ACCENT = "#5B7CFA";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** The placeholder stands in for a photograph, so it stays literal. */
const PLACEHOLDER_ART = "linear-gradient(145deg, #6E8BFA, #9A6BF0)";
const money = (value: number) =>
`$${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
/**
* One value in a fixed slot. The outgoing figure leaves the way the new
* one arrives, so the roll carries the direction of the change — up when
* the number grew, down when it shrank.
*/
function Roll({
value,
direction,
width,
height,
fontSize,
fontWeight,
travel,
duration,
align,
}: {
value: string;
direction: number;
width: number;
height: number;
fontSize: number;
fontWeight: number;
travel: number;
duration: number;
align: "center" | "right";
}) {
return (
<span
style={{
position: "relative",
display: "block",
width,
height,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={value}
initial={{ y: direction * travel, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -direction * travel, opacity: 0 }}
transition={{ duration, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: align === "right" ? "flex-end" : "center",
fontSize,
fontWeight,
lineHeight: `${height}px`,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}
export default function QuantityStepperCart({
variant = "default",
unitPrice = 19,
defaultQuantity = 2,
otherItemsTotal = 92.5,
imageSrc,
onQuantityChange,
}: QuantityStepperCartProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [quantity, setQuantity] = useState(Math.max(1, defaultQuantity));
const [direction, setDirection] = useState(1);
// Reduced motion: the figures still change, they just swap in place
// instead of travelling. Direction stops being expressed as movement.
const travel = reduceMotion ? 0 : cfg.travel;
const roll = reduceMotion ? 0.12 : cfg.roll;
const step = (delta: number) => {
const next = Math.min(9, Math.max(1, quantity + delta));
if (next === quantity) return;
setDirection(delta > 0 ? 1 : -1);
setQuantity(next);
onQuantityChange?.(next);
};
const lineTotal = unitPrice * quantity;
const cartTotal = otherItemsTotal + lineTotal;
const stepperButton = (delta: number, label: string, path: string) => {
const disabled = delta < 0 ? quantity <= 1 : quantity >= 9;
return (
<motion.button
type="button"
onClick={() => step(delta)}
disabled={disabled}
aria-label={label}
whileTap={disabled || reduceMotion ? undefined : { scale: 0.9 }}
transition={cfg.press}
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
borderRadius: 8,
border: "none",
background: "transparent",
color: "inherit",
opacity: disabled ? 0.3 : 0.85,
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
aria-hidden
>
<path d={path} />
</svg>
</motion.button>
);
};
return (
<div
style={{
width: 330,
padding: 16,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<span
aria-hidden
style={{
width: 54,
height: 54,
flexShrink: 0,
borderRadius: 12,
background: PLACEHOLDER_ART,
overflow: "hidden",
}}
>
{imageSrc && (
<img
src={imageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
)}
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 13, fontWeight: 640 }}>
Ceramic dripper
</span>
<span
style={{
display: "block",
fontSize: 11.5,
opacity: 0.55,
marginTop: 2,
fontVariantNumeric: "tabular-nums",
}}
>
{money(unitPrice)} each · Matte white
</span>
</span>
<span
style={{
display: "flex",
alignItems: "center",
borderRadius: 10,
border: `1px solid ${tone(14)}`,
background: tone(5),
padding: 2,
}}
>
{stepperButton(-1, "Reduce quantity", "M4.5 10h11")}
<Roll
value={String(quantity)}
direction={direction}
width={22}
height={20}
fontSize={13}
fontWeight={660}
travel={travel}
duration={roll}
align="center"
/>
{stepperButton(1, "Increase quantity", "M10 4.5v11M4.5 10h11")}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 14,
paddingTop: 12,
borderTop: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 12, opacity: 0.6 }}>Line total</span>
<Roll
value={money(lineTotal)}
direction={direction}
width={92}
height={19}
fontSize={13}
fontWeight={640}
travel={travel}
duration={roll}
align="right"
/>
</div>
{/* The cart sum is downstream of the line, so it moves less: a
shorter travel reads as a consequence rather than a second
announcement competing with the first. */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 9,
}}
>
<span style={{ fontSize: 13.5, fontWeight: 660 }}>Cart subtotal</span>
<Roll
value={money(cartTotal)}
direction={direction}
width={104}
height={22}
fontSize={15.5}
fontWeight={680}
travel={Math.round(travel * 0.6)}
duration={roll * 1.1}
align="right"
/>
</div>
<button
type="button"
style={{
marginTop: 14,
width: "100%",
padding: "10px 14px",
fontSize: 13,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 11,
border: "none",
background: ACCENT,
color: "#FFFFFF",
cursor: "pointer",
}}
>
Go to checkout
</button>
</div>
);
}About this pattern
A cart line where every figure downstream of the stepper answers it. The outgoing number leaves the way the new one arrives, so the roll itself carries the direction — up for more, down for less — and the cart sum travels a shorter distance than the line total, which reads as a consequence rather than a second announcement competing with the first. Figures sit in fixed-width tabular slots so a 9 becoming a 10 cannot nudge the row sideways, and they translate and crossfade rather than scaling: the one thing a shopper must trust at a glance is the number.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Cart
Adjusting an item's count animates the line and basket figures in the direction of the change.
Related patterns
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
- Quantity AdjustThe count rolls in the direction it moved and the order total takes a brief tint.
- Stepper IncrementThe number rolls up on increase and down on decrease, so the two buttons never look alike.
