Quantity Adjust
The count rolls in the direction it moved and the order total takes a brief tint.
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 · Quantity Adjust
*
* Changing how many of something is in the basket. The count rolls in
* the direction it moved, the line total rolls with it, and the order
* total takes a brief tint so the number further down the page is not
* missed.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so it reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `item`, `currencySymbol`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CartLine = {
/** Product name. */
name: string;
/** Short second line — size, variant, whatever the row needs. */
detail: string;
/** Unit price in minor units, so no float ever reaches the total. */
unitPriceMinor: number;
/** Placeholder swatch for the product image. */
swatch: string;
};
export type QuantityAdjustProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The line being adjusted. */
item?: CartLine;
/** Starting quantity. */
defaultQuantity?: number;
/** Bounds for the stepper. */
min?: number;
max?: number;
/** Other items already in the basket, in minor units. */
otherLinesMinor?: number;
/** Prefixed to every amount. */
currencySymbol?: string;
/** Accent for the emphasis tint. */
accent?: string;
/** Fires with the new quantity. */
onQuantityChange?: (quantity: number) => void;
};
type VariantConfig = {
/** Seconds a digit takes to roll out and the next to roll in. */
rollSeconds: number;
/** Seconds the order total holds its tint. */
emphasisSeconds: number;
};
// Quality rule: digits are text, so they translate and never scale — a
// count that pops larger on each tap is the cheapest tell in commerce
// UI. Nothing springs; a total that bounces past its value shows the
// wrong number, however briefly.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short slide. For a basket with a dozen rows in it.
subtle: { rollSeconds: 0.14, emphasisSeconds: 0.5 },
// A readable roll with a clear echo on the total. The all-purpose
// setting.
default: { rollSeconds: 0.2, emphasisSeconds: 0.8 },
// A fuller roll and a longer tint, for a single-item checkout.
playful: { rollSeconds: 0.28, emphasisSeconds: 1.1 },
};
/** 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
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_ITEM: CartLine = {
name: "Linen throw blanket",
detail: "Sandstone · 130 × 170 cm",
unitPriceMinor: 6400,
swatch: "#C7A98B",
};
/** Formatted here rather than through Intl so the server and the client
* always produce the same string. */
function money(minor: number, symbol: string) {
const units = (minor / 100).toFixed(2);
const [whole, fraction] = units.split(".");
return `${symbol}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${fraction}`;
}
type RollerProps = {
value: string;
direction: number;
width: number | string;
height: number;
align: "flex-start" | "center" | "flex-end";
seconds: number;
still: boolean;
};
/** One value at a time inside a fixed box: the outgoing figure leaves the
* way the count went and the incoming one arrives from the other side. */
function Roller({
value,
direction,
width,
height,
align,
seconds,
still,
}: RollerProps) {
return (
<span
aria-hidden
style={{
position: "relative",
display: "inline-block",
width,
height,
overflow: "hidden",
verticalAlign: "bottom",
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={value}
initial={still ? { opacity: 0 } : { y: direction * height, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={
still
? { opacity: 0 }
: { y: -direction * height, opacity: 0 }
}
transition={{ duration: still ? 0.1 : seconds, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: align,
}}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}
export default function QuantityAdjust({
variant = "default",
item = DEFAULT_ITEM,
defaultQuantity = 2,
min = 1,
max = 9,
otherLinesMinor = 4250,
currencySymbol = "$",
accent = "#5B5BD6",
onQuantityChange,
}: QuantityAdjustProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [quantity, setQuantity] = useState(defaultQuantity);
const [direction, setDirection] = useState(1);
const [touched, setTouched] = useState(false);
const [ring, setRing] = useState<string | null>(null);
const lineTotal = quantity * item.unitPriceMinor;
const orderTotal = lineTotal + otherLinesMinor;
const adjust = (delta: number) => {
const next = Math.min(max, Math.max(min, quantity + delta));
if (next === quantity) return;
setDirection(delta > 0 ? 1 : -1);
setQuantity(next);
setTouched(true);
onQuantityChange?.(next);
};
const stepButton = (delta: number, label: string) => {
const disabled = delta > 0 ? quantity >= max : quantity <= min;
const key = delta > 0 ? "plus" : "minus";
return (
<button
type="button"
onClick={() => adjust(delta)}
aria-label={label}
disabled={disabled}
onFocus={(event) =>
setRing(event.currentTarget.matches(":focus-visible") ? key : null)
}
onBlur={() => setRing(null)}
style={{
display: "grid",
placeItems: "center",
width: 26,
height: 26,
padding: 0,
fontFamily: "inherit",
color: "inherit",
opacity: disabled ? 0.3 : 0.85,
background: "transparent",
border: "none",
borderRadius: 7,
cursor: disabled ? "default" : "pointer",
boxShadow: ring === key ? `0 0 0 3px ${tone(20)}` : "none",
outline: "none",
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d={delta > 0 ? "M6 1.8v8.4M1.8 6h8.4" : "M1.8 6h8.4"}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
);
};
return (
<div style={{ width: 316, color: "inherit" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: 12,
borderRadius: 13,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
{/* A literal swatch: it stands in for a product photo, not for a
surface, so it keeps its own colour in both themes. */}
<span
aria-hidden
style={{
flex: "0 0 auto",
width: 44,
height: 44,
borderRadius: 9,
background: item.swatch,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 12.5,
fontWeight: 650,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.name}
</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 2 }}>
{item.detail}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
marginTop: 8,
}}
>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 2,
padding: 2,
borderRadius: 9,
border: `1px solid ${tone(13)}`,
background: tone(6),
}}
>
{stepButton(-1, "Decrease quantity")}
<Roller
value={String(quantity)}
direction={direction}
width={22}
height={22}
align="center"
seconds={cfg.rollSeconds}
still={Boolean(reduceMotion)}
/>
{stepButton(1, "Increase quantity")}
</div>
<span style={{ marginLeft: "auto", fontSize: 12.5, fontWeight: 650 }}>
<Roller
value={money(lineTotal, currencySymbol)}
direction={direction}
width={68}
height={18}
align="flex-end"
seconds={cfg.rollSeconds}
still={Boolean(reduceMotion)}
/>
</span>
</div>
</div>
</div>
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginTop: 8,
padding: "9px 12px",
borderRadius: 11,
overflow: "hidden",
}}
>
{/*
The echo on the total: a tint that comes up behind the row and
goes again. color-mix() results cannot be interpolated, so the
layer is a static mix whose opacity is what animates — and the
figure itself never changes size.
*/}
{touched && (
<motion.span
key={orderTotal}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 1, 0] }}
transition={{
duration: cfg.emphasisSeconds,
times: reduceMotion ? [0, 0.2, 1] : [0, 0.16, 0.55, 1],
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
borderRadius: 11,
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
}}
/>
)}
<span style={{ position: "relative", fontSize: 12, opacity: 0.6 }}>
Order total
</span>
<span
style={{ position: "relative", fontSize: 14.5, fontWeight: 700 }}
>
<Roller
value={money(orderTotal, currencySymbol)}
direction={direction}
width={82}
height={20}
align="flex-end"
seconds={cfg.rollSeconds}
still={Boolean(reduceMotion)}
/>
</span>
<span
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
margin: -1,
padding: 0,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{quantity} in the basket, order total {money(orderTotal, currencySymbol)}
</span>
</div>
</div>
);
}About this pattern
Changing how many of something is in a basket, with the consequence made visible. The count rolls the way it went — up for more, down for fewer — the line amount rolls with it, and the order total further down the panel picks up a short tint so the number that actually matters is not missed. Figures are text: they translate inside a fixed box and never scale, because a count that pops larger on every tap is the cheapest tell in commerce UI. Nothing springs, since a total that overshoots shows a wrong number, however briefly.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Cart
Adjusting a line quantity updates the summary total in the same beat.
Related patterns
- Stepper IncrementThe number rolls up on increase and down on decrease, so the two buttons never look alike.
- Cart Quantity StepperStepping the quantity rolls the count and both totals in the direction of the change.
- File Drop Zone ActiveThe dashed outline energizes and the target lifts off the page while a file is held over it.
