All patterns

Quantity Adjust

The count rolls in the direction it moved and the order total takes a brief tint.

formsfriendlyenergeticinteraction · finite · intermediate · ~0.8s
Interactive · click to play
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.

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

Change quantity in a basketStepper on a line itemSeat or licence countOrder total updating

Where it shows up

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

  • Your bag
    Ridgeline GT — Bone, US 91+$132.00
    Merino crew sock, 2-pack2+$24.00
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Checkout
    Cart

    Adjusting a line quantity updates the summary total in the same beat.

Related patterns