# Stepper

Number stepper whose digits roll in the direction you press. Hold a button to repeat.

**Category:** Inputs  
**Exports:** `Stepper`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/stepper.json
```
The file lands at `components/ui/stepper.tsx`; import from `@/components/ui/stepper`.

## API — Stepper
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `99` |  |
| `step` | `number` | `1` |  |
| `aria-label` | `string` | — |  |

## Example — Stepper

```tsx
import { Stepper } from "@/components/ui/stepper";

export default function StepperDemo({ min = 1, max = 120, step = 1 }: { min?: number; max?: number; step?: number }) {
  return (
    <div className="flex flex-col items-center gap-3">
      <Stepper aria-label="Guests" defaultValue={8} min={min} max={max} step={step} />
      <p className="text-sm text-muted-foreground">Hold a button to count faster.</p>
    </div>
  );
}
```

## Source — components/ui/stepper.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type StepperProps = {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  className?: string;
  "aria-label"?: string;
};

function RollingDigits({ value, direction }: { value: number; direction: number }) {
  const reduce = useReducedMotion();
  const chars = String(value).split("");
  return (
    <span className="inline-flex overflow-hidden tabular-nums" aria-hidden>
      {chars.map((ch, i) => (
        <span key={chars.length - i} className="relative inline-block">
          <AnimatePresence mode="popLayout" initial={false} custom={direction}>
            <motion.span
              key={ch}
              className="inline-block"
              custom={direction}
              variants={{
                enter: (d: number) => ({ y: reduce ? 0 : `${d * 70}%`, opacity: 0, filter: "blur(2px)" }),
                center: { y: 0, opacity: 1, filter: "blur(0px)" },
                exit: (d: number) => ({ y: reduce ? 0 : `${-d * 70}%`, opacity: 0, filter: "blur(2px)" }),
              }}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ type: "spring", stiffness: 500, damping: 34 }}
            >
              {ch}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

export function Stepper({ value: valueProp, defaultValue = 0, onValueChange, min = 0, max = 99, step = 1, className, ...aria }: StepperProps) {
  const [inner, setInner] = React.useState(defaultValue);
  const value = valueProp ?? inner;
  const [direction, setDirection] = React.useState(1);
  const valueRef = React.useRef(value);
  valueRef.current = value;
  const hold = React.useRef<{ t?: number; i?: number }>({});

  const change = React.useCallback(
    (delta: number) => {
      const next = Math.min(max, Math.max(min, valueRef.current + delta));
      if (next === valueRef.current) return;
      valueRef.current = next;
      setDirection(delta > 0 ? 1 : -1);
      if (valueProp === undefined) setInner(next);
      onValueChange?.(next);
    },
    [min, max, valueProp, onValueChange],
  );

  const stop = () => {
    window.clearTimeout(hold.current.t);
    window.clearInterval(hold.current.i);
  };
  React.useEffect(() => stop, []);

  const start = (delta: number) => (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    change(delta);
    stop();
    // The button may disable itself at min/max mid-hold and miss its own pointerup.
    window.addEventListener("pointerup", stop, { once: true });
    hold.current.t = window.setTimeout(() => {
      hold.current.i = window.setInterval(() => change(delta), 70);
    }, 420);
  };

  const btn = (delta: number, label: string, icon: React.ReactNode, disabled: boolean) => (
    <motion.button
      type="button"
      aria-label={label}
      disabled={disabled}
      onPointerDown={start(delta)}
      onPointerUp={stop}
      onPointerLeave={stop}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          change(delta);
        }
      }}
      whileTap={{ scale: 0.88 }}
      className="grid size-10 cursor-pointer place-items-center rounded-lg text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-35"
    >
      {icon}
    </motion.button>
  );

  return (
    <div
      role="spinbutton"
      tabIndex={0}
      aria-valuenow={value}
      aria-valuemin={min}
      aria-valuemax={max}
      {...aria}
      onKeyDown={(e) => {
        if (e.key === "ArrowUp" || e.key === "ArrowRight") change(step);
        else if (e.key === "ArrowDown" || e.key === "ArrowLeft") change(-step);
        else return;
        e.preventDefault();
      }}
      className={cn(
        "inline-flex h-12 items-center gap-1 rounded-xl border bg-card p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
    >
      {btn(-step, "Decrease", <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"><path d="M6 12h12" /></svg>, value <= min)}
      <span className="min-w-12 text-center text-lg font-semibold">
        <RollingDigits value={value} direction={direction} />
      </span>
      {btn(step, "Increase", <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"><path d="M12 6v12M6 12h12" /></svg>, value >= max)}
    </div>
  );
}
```