Price Change Roll
A recalculated total rolls digit by digit in the direction the money moved.
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 · Price Change Roll
*
* A recalculated total rolls into its new value one digit at a time:
* changed digits travel in the direction the money moved, currency
* symbols and separators stay nailed down, and a delta chip says how
* much and which way.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `before`, `after`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PriceLine = {
label: string;
/** Formatted for display — formatting and currency stay yours. */
before: string;
after: string;
};
export type PriceChangeRollProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Heading above the summary. */
title?: string;
/** Rows that recalculate alongside the total. */
lines?: PriceLine[];
/** The headline amount, before the recalculation. */
before?: string;
/** The headline amount, after it. */
after?: string;
/** Chip shown beside the total once it has moved. */
deltaLabel?: string;
/** Why the number changed, in a few words. */
reason?: string;
/** Beat before the recalculation lands, in ms. */
delayMs?: number;
/** Fires once the new total is in place. */
onSettled?: () => void;
};
type VariantConfig = {
/** Spring a changed digit rides into place. */
roll: { type: "spring"; stiffness: number; damping: number };
/** Gap between one digit starting its roll and the next. */
stagger: number;
/** Crossfade length for the outgoing and incoming digit. */
fadeSeconds: number;
};
// Money has to be readable the instant it stops, so the digits never
// overshoot far: damping ratios (damping / 2√stiffness) sit at or above
// 0.88. Variants change the speed and the size of the cascade, not the
// bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// All digits at once, almost a cut. For totals that recalculate on
// every keystroke.
subtle: {
roll: { type: "spring", stiffness: 620, damping: 48 },
stagger: 0,
fadeSeconds: 0.12,
},
// A short cascade left to right — the way a price is read. All-purpose.
default: {
roll: { type: "spring", stiffness: 460, damping: 40 },
stagger: 0.045,
fadeSeconds: 0.16,
},
// A longer cascade for a checkout where the new total is the headline.
playful: {
roll: { type: "spring", stiffness: 320, damping: 33 },
stagger: 0.075,
fadeSeconds: 0.2,
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The savings color stays literal — it carries meaning. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAVING = "#10B981";
const RISE = "#E0763C";
const DEFAULT_LINES: PriceLine[] = [
{ label: "Subtotal", before: "$248.00", after: "$248.00" },
{ label: "Shipping", before: "$12.00", after: "$0.00" },
{ label: "Estimated tax", before: "$21.60", after: "$19.42" },
];
/** Numeric value behind a formatted string, so the roll knows which way
* the money went without the caller having to say. */
const numericOf = (value: string) =>
Number(value.replace(/[^0-9.-]/g, "")) || 0;
/**
* A formatted amount whose changed digits roll and whose punctuation
* holds. Exported because a cart usually has more than one number that
* moves at the same moment.
*/
export function RollingAmount({
value,
previous,
direction,
cfg,
still,
lineHeight,
}: {
value: string;
previous: string;
direction: 1 | -1;
cfg: VariantConfig;
still: boolean;
lineHeight: number;
}) {
const chars = value.split("");
const previousChars = previous.split("");
// Right-aligned comparison: in 12.00 → 9.00 the units column really did
// change, and the vanished tens column was never the same slot.
const offset = chars.length - previousChars.length;
return (
<span
style={{
display: "inline-flex",
alignItems: "flex-start",
fontVariantNumeric: "tabular-nums",
lineHeight: `${lineHeight}px`,
}}
>
{chars.map((char, index) => {
const unchanged = previousChars[index - offset] === char;
const rolls = !still && !unchanged && /[0-9]/.test(char);
if (!rolls) {
return (
<span key={`fixed-${index}`} style={{ display: "inline-block" }}>
{char}
</span>
);
}
const travel = direction * lineHeight;
return (
<span
key={`slot-${index}`}
style={{
display: "inline-grid",
height: lineHeight,
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: { ...cfg.roll, delay: index * cfg.stagger },
opacity: {
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: index * cfg.stagger,
},
}}
style={{ gridArea: "1 / 1", display: "block" }}
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
);
}
export default function PriceChangeRoll({
variant = "default",
title = "Order summary",
lines = DEFAULT_LINES,
before = "$281.60",
after = "$267.42",
deltaLabel = "You save $14.18",
reason = "Member pricing applied",
delayMs = 900,
onSettled,
}: PriceChangeRollProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [applied, setApplied] = useState(false);
const onSettledRef = useRef(onSettled);
useEffect(() => {
onSettledRef.current = onSettled;
}, [onSettled]);
useEffect(() => {
const timer = setTimeout(() => {
setApplied(true);
onSettledRef.current?.();
}, delayMs);
return () => clearTimeout(timer);
}, [delayMs]);
const total = applied ? after : before;
const totalPrevious = applied ? before : after;
const fell = numericOf(after) <= numericOf(before);
// Down when the money went down. The direction is the fastest read on
// the whole panel — it says which way before any digit is legible.
const direction: 1 | -1 = fell ? -1 : 1;
const deltaColor = fell ? SAVING : RISE;
return (
<div
style={{
width: 264,
padding: "14px 16px 16px",
borderRadius: 14,
background: tone(5),
border: `1px solid ${tone(10)}`,
fontSize: 13,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
opacity: 0.55,
letterSpacing: 0.2,
textTransform: "uppercase",
}}
>
{title}
</div>
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
{lines.map((line) => (
<div
key={line.label}
style={{ display: "flex", alignItems: "center", gap: 10 }}
>
<span style={{ fontSize: 12.5, opacity: 0.6 }}>{line.label}</span>
<span style={{ marginLeft: "auto", fontSize: 12.5 }}>
<RollingAmount
value={applied ? line.after : line.before}
previous={applied ? line.before : line.after}
direction={direction}
cfg={cfg}
still={Boolean(reduceMotion)}
lineHeight={17}
/>
</span>
</div>
))}
</div>
<div
style={{
height: 1,
background: tone(12),
margin: "13px 0",
}}
/>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>Total</span>
<span
style={{
marginLeft: "auto",
fontSize: 22,
fontWeight: 650,
letterSpacing: -0.3,
}}
>
<RollingAmount
value={total}
previous={totalPrevious}
direction={direction}
cfg={cfg}
still={Boolean(reduceMotion)}
lineHeight={28}
/>
</span>
</div>
{/* The chip explains the roll. It fades and lifts a few pixels —
the sentence inside keeps one size the whole way. */}
<motion.div
initial={false}
animate={{
opacity: applied ? 1 : 0,
y: applied || reduceMotion ? 0 : 5,
}}
transition={{
duration: 0.26,
ease: "easeOut",
delay: applied && !reduceMotion ? 0.16 : 0,
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 12,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "3px 8px",
borderRadius: 999,
fontSize: 11.5,
fontWeight: 600,
color: deltaColor,
background: `color-mix(in srgb, ${deltaColor} 14%, transparent)`,
}}
>
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d={
fell
? "M6 1.6v8.8M6 10.4 2.9 7.3M6 10.4l3.1-3.1"
: "M6 10.4V1.6M6 1.6 2.9 4.7M6 1.6l3.1 3.1"
}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{deltaLabel}
</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{reason}</span>
</motion.div>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{applied ? `New total ${after}. ${deltaLabel}.` : ""}
</span>
</div>
);
}About this pattern
When a total is recalculated the reader needs two things at once: the new figure, and which way it went. This rolls only the digits that actually changed, downward for a saving and upward for a rise, in a short cascade read left to right, while currency symbols and separators stay nailed in place. A delta chip lands a beat later with the size of the change and the reason for it. The type size never changes — a price that grows and shrinks mid-roll is unreadable exactly when someone 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
Fare estimate settles on a new figure as options change, digits moving in place.
Related patterns
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
- Cart Quantity StepperStepping the quantity rolls the count and both totals in the direction of the change.
- Cart Drawer OpenThe cart panel travels in from the edge and its line items arrive a beat behind it.
