All patterns

Stepper Increment

The number rolls up on increase and down on decrease, so the two buttons never look alike.

formsminimalfriendlyinteraction · finite · starter · ~0.2s
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.

247 lines · react + motion only
import { useId, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Stepper Increment
 *
 * The number rolls in the direction of the change — up on increase, down
 * on decrease — so the two buttons never produce the same movement. A
 * real spinbutton: arrow keys, Page keys, Home and End, with the value
 * exposed as `aria-valuenow` and the bounds as min/max.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so the control reads correctly on
 * a light page and on a dark one.
 * Works with zero props; tune via `variant`, `label`, `min`, `max`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type StepperIncrementProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Control label. Also the spinbutton's accessible name. */
  label?: string;
  min?: number;
  max?: number;
  step?: number;
  /** Starting value. */
  defaultValue?: number;
  /** Accent for the focus ring. */
  accent?: string;
  /** Fires with every committed value. */
  onValueChange?: (value: number) => void;
};

type VariantConfig = {
  /** How far the digit travels as it rolls, in pixels. */
  travel: number;
  /** Seconds for the roll. */
  roll: number;
  /** Gives the pressed button somewhere to go. */
  press: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the digit is text, so it translates and cross-fades at a
// constant size — never scales, and never on a spring, because a number
// that overshoots and comes back is a number that has been misread once
// already. The only spring here drives the button chrome, and it sits
// above a 0.8 damping ratio. Variants differ in travel and tempo.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short slip, barely more than a cross-fade.
  subtle: {
    travel: 8,
    roll: 0.14,
    press: { type: "spring", stiffness: 700, damping: 50 },
  },
  // The direction of the change is unmistakable. All-purpose.
  default: {
    travel: 14,
    roll: 0.19,
    press: { type: "spring", stiffness: 560, damping: 42 },
  },
  // A full roll, for a quantity that changes one or two times.
  playful: {
    travel: 20,
    roll: 0.24,
    press: { type: "spring", stiffness: 420, damping: 34 },
  },
};

/** 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 or border that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const ICONS = {
  minus: "M4 10 H16",
  plus: "M10 4 V16 M4 10 H16",
};

export default function StepperIncrement({
  variant = "default",
  label = "Seats",
  min = 1,
  max = 12,
  step = 1,
  defaultValue = 3,
  accent = "#5B5BD6",
  onValueChange,
}: StepperIncrementProps) {
  // The value and the way it last moved are one fact, not two: the roll
  // is rendered from the direction, so a decrease can never be drawn
  // with a stale sign left over from the press before it.
  const [reading, setReading] = useState({ value: defaultValue, direction: 1 });
  const { value, direction } = reading;
  const [ring, setRing] = useState(false);
  const labelId = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const commit = (next: number) => {
    const clamped = Math.min(max, Math.max(min, next));
    if (clamped === value) return;
    setReading({ value: clamped, direction: clamped > value ? 1 : -1 });
    onValueChange?.(clamped);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const jump = step * 5;
    let next: number | null = null;
    if (event.key === "ArrowUp" || event.key === "ArrowRight") next = value + step;
    else if (event.key === "ArrowDown" || event.key === "ArrowLeft") next = value - step;
    else if (event.key === "PageUp") next = value + jump;
    else if (event.key === "PageDown") next = value - jump;
    else if (event.key === "Home") next = min;
    else if (event.key === "End") next = max;
    if (next === null) return;
    event.preventDefault();
    commit(next);
  };

  const button = (kind: "minus" | "plus") => {
    const delta = kind === "plus" ? step : -step;
    const disabled = kind === "plus" ? value >= max : value <= min;
    return (
      <motion.button
        type="button"
        aria-label={`${kind === "plus" ? "Increase" : "Decrease"} ${label.toLowerCase()}`}
        disabled={disabled}
        onClick={() => commit(value + delta)}
        whileTap={disabled || reduceMotion ? undefined : { scale: 0.86 }}
        transition={cfg.press}
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          width: 32,
          height: 32,
          padding: 0,
          borderRadius: 9,
          border: `1px solid ${tone(14)}`,
          background: tone(6),
          color: "inherit",
          opacity: disabled ? 0.35 : 1,
          cursor: disabled ? "not-allowed" : "pointer",
          outline: "none",
          transition: "opacity 140ms ease-out",
          WebkitTapHighlightColor: "transparent",
        }}
      >
        <svg viewBox="0 0 20 20" width={15} height={15} fill="none" aria-hidden>
          <path
            d={ICONS[kind]}
            stroke="currentColor"
            strokeWidth={1.8}
            strokeLinecap="round"
          />
        </svg>
      </motion.button>
    );
  };

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        gap: 18,
        width: 260,
        color: "inherit",
      }}
    >
      <span id={labelId} style={{ fontSize: 13.5, fontWeight: 600 }}>
        {label}
      </span>

      <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
        {button("minus")}

        <div
          role="spinbutton"
          tabIndex={0}
          aria-labelledby={labelId}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
          onKeyDown={onKeyDown}
          // The ring is for keyboard users only. `:focus-visible` is the
          // browser's own answer to "was this focus deliberate?" — read
          // it instead of guessing at the input modality.
          onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
          onBlur={() => setRing(false)}
          style={{
            position: "relative",
            width: 46,
            height: 32,
            borderRadius: 9,
            // The window the digit rolls through. Without it the outgoing
            // number would be visible sliding past the buttons.
            overflow: "hidden",
            outline: "none",
            boxShadow: ring ? `0 0 0 3px ${accent}66` : "none",
            transition: "box-shadow 140ms ease-out",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={value}
              initial={{
                y: reduceMotion ? 0 : direction * cfg.travel,
                opacity: 0,
              }}
              animate={{ y: 0, opacity: 1 }}
              exit={{
                y: reduceMotion ? 0 : -direction * cfg.travel,
                opacity: 0,
              }}
              transition={{
                duration: reduceMotion ? 0.1 : cfg.roll,
                ease: "easeOut",
              }}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                // Constant size, tabular figures: the roll is the whole
                // signal, and a digit that also changes width would make
                // the row twitch.
                fontSize: 15,
                fontWeight: 650,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {value}
            </motion.span>
          </AnimatePresence>
        </div>

        {button("plus")}
      </div>
    </div>
  );
}

About this pattern

A counter whose movement carries the sign of the change. The outgoing digit leaves through the top and the new one arrives from the bottom when the value goes up, and the reverse when it goes down, which means a mis-pressed button is visible before the number is even read. The digit is text, so it translates and cross-fades at a constant size on an eased tween: no scaling, and no spring, because a number that overshoots and comes back has already been misread once. Tabular figures keep the row from twitching as the width of the value changes.

Seat or license countCart line itemBooking party sizeNumeric setting

Where it shows up

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

  • 10:15
    Add a supplierTwo fields now, the rest later
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Save supplier
    Form

    A stepper used many times in a row, where direction has to stay legible.

Related patterns