All patterns

Price Change Roll

A recalculated total rolls digit by digit in the direction the money moved.

commercepremiumminimalautomatic · finite · intermediate · ~1.1s
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.

359 lines · react + motion only
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.

Total recalculating at checkoutCurrency switch on a pricing pageEstimate updating as quantity changesLive tax and shipping recalculation

Where it shows up

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

  • NewMenWomenSale
    Men's trail shoeRidgeline GT$132Bone
    88.599.510
    Add to bag
    Free delivery and returns
    Product details
    Product page

    Fare estimate settles on a new figure as options change, digits moving in place.

Related patterns