Components

Getting started

Components113

More

Stepper inputNew

A tactile incrementer: tap to step, hold to accelerate, bounds give an elastic bumper shake, and digits roll in place.

Used forBilling & seatsCanvas & design inspectorsE-commerce cartsModel parameters

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/stepper-input.json

Where it’s used

  • Billing & seats

    Adjust workspace licenses or team member counts with immediate price calculations.

  • Canvas & design inspectors

    Nudge padding, border-radius, font size, or opacity with hold-to-accelerate speed.

  • E-commerce carts

    Quantity steppers that prevent zero-quantity mistakes and clamp to warehouse inventory.

  • Model parameters

    Tune temperature, top-p, and context window limits with fine-grained decimal steps.

What it handles for you

Building a custom number stepper seems trivial until you need hold-to-accelerate timing, bound checking, keyboard arrow accessibility, decimal precision rounding, and seamless direct text editing. This handles all of it.

Instead of wiring upnative <input type="number">custom setInterval loopsboundary clamping logicdirect text parsingodometer synckeyboard spinbutton handlers
  1. Odometer roll direction

    Increments roll upward and decrements roll downward through NumberRoll, maintaining continuous directional feedback.

  2. Exponential hold acceleration

    Holds start with a 300ms delay to prevent accidental runs, then step at 90ms, 55ms, and 30ms with automatic 5x/10x multipliers.

  3. Elastic bumper feedback

    Hitting the min or max bound produces a brief horizontal elastic shake rather than dead-ending without feedback.

  4. Direct editing in place

    Clicking the number reveals a full input without shifting a pixel; Enter or blur parses, clamps, and commits.

  5. Floating-point precision

    Handles fractional steps like 0.1 or 0.05 without standard JavaScript floating-point artifacts like 0.30000000000000004.

  6. Accessible spinbutton

    Supports ArrowUp/Down, PageUp/Down for large steps, and Home/End for boundary limits, with polite screen-reader announcements.

Overview

Number inputs usually force manual typing or rely on tiny browser stepper arrows that ignore your theme and lack feel. This is a tactile hairline stepper: single taps roll the odometer in the direction of change; holding down a button begins with a measured 300ms pause before accelerating smoothly down to 30ms intervals with scaled steps. Reaching min or max limits disables the trigger and produces an elastic bumper shake. Clicking the middle opens a clean inline input that parses, clamps, and commits on blur or Enter.

States

rest
Hairline pill or field container in rest state. Decrement and increment buttons display quiet icons with disabled styling when at bounds.
tap
Single tap or click advances by one step. The digit rolls in the direction the value changed over 260ms.
accelerate
Holding down past 300ms activates rapid stepping, ramping up to 30ms tick intervals with large-step multipliers.
limit shake
Attempting to step past min or max triggers an elastic bumper shake (±3px over 280ms) and disables the button.
direct edit
Clicking the value swaps the odometer for an active text input; Escape cancels back to original value, Enter commits clamped number.

Usage

usage.tsx
import { StepperInput } from "@/components/stepper-input";<StepperInput  label="Workspace seats"  defaultValue={5}  min={1}  max={50}  unit="seats"  onValueChange={(seats) => updateSeats(seats)}/>

Works with

Team seat checkout with live pricing

StepperInput controls team seat allocation with minimum and maximum thresholds. Value updates roll instantly and feed directly into cost calculation.

example.tsx
// app/billing/seats-calculator.tsx"use client";import * as React from "react";import { StepperInput } from "@/components/stepper-input";import { NumberRoll } from "@/components/number-roll";const PRICE_PER_SEAT = 15;export function SeatsCalculator() {  const [seats, setSeats] = React.useState(5);  const total = seats * PRICE_PER_SEAT;  return (    <div className="flex items-center justify-between rounded-xl border border-border p-4">      <div>        <p className="text-sm font-medium text-foreground">Team seats</p>        <p className="text-xs text-muted-foreground">$15 / seat / month</p>      </div>      <div className="flex items-center gap-4">        <StepperInput          label="Seats"          value={seats}          min={1}          max={100}          unit="seats"          onValueChange={setSeats}        />        <div className="min-w-[4.5rem] text-right font-mono text-sm font-medium text-foreground">          <NumberRoll value={total} prefix="$" />          <span className="text-xs text-muted-foreground">/mo</span>        </div>      </div>    </div>  );}

Props

PropTypeDescription
labelstringAccessible name for the stepper, announced to assistive technologies.
valuenumberThe controlled numeric value.
defaultValuenumberdefault 0Initial numeric value when uncontrolled.
onValueChange(value: number) => voidCallback invoked when the value changes.
minnumberdefault -InfinityMinimum allowed value.
maxnumberdefault InfinityMaximum allowed value.
stepnumberdefault 1Amount to increment or decrement on single steps.
largeStepnumberStep multiplier for PageUp/PageDown and sustained acceleration (defaults to step * 10).
unitstringOptional text suffix displayed beside the value (e.g. "seats", "px", "%").
formatIntl.NumberFormatOptionsFormatting options forwarded to NumberRoll.
variant"pill" | "field"default "pill"Border radius style: pill (rounded-full) or field (rounded-lg).
size"sm" | "default"default "default"Control height and typography size: sm (28px) or default (36px).
disabledbooleandefault falseDisables all interactions and dims the control.
namestringName attribute for hidden input submitted with HTML forms.

Accessibility and motion

  • Uses NumberRoll internally so numbers roll in the exact direction of travel (increment rolls up, decrement rolls down).
  • Hold-to-accelerate uses touch-safe pointer listeners and clears all timers on pointer cancel or unmount.
  • Arrow keys, PageUp/PageDown, and Home/End support the full WAI-ARIA spinbutton specification.
  • Direct text edit automatically clamps and rounds to the specified step precision to eliminate floating-point arithmetic errors.
  • Reduced motion replaces the bumper shake and roll animations with instant updates.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll.

Source

components/stepper-input.tsx
"use client";import * as React from "react";import { NumberRoll } from "./number-roll";/* ───────────────────────────────────────────────────────── * STEPPER INPUT: a tactile number control with acceleration * *   single tap   increments or decrements by step, rolling *                the odometer in the direction of travel *   holding      waits 300ms, then accelerates smoothly from *                100ms down to 30ms intervals with scaled steps *   boundary     hitting min or max bounds disables the action *                and gives the container an elastic bumper shake *   direct edit  clicking the number reveals an exact input, *                committing on blur or Enter, cancelling on Escape * ───────────────────────────────────────────────────────── */export interface StepperInputProps {  /** Accessible label, e.g. "Team seats" or "Border radius". */  label: string;  value?: number;  defaultValue?: number;  onValueChange?: (value: number) => void;  min?: number;  max?: number;  step?: number;  /** Large step for PageUp/PageDown and prolonged acceleration. Defaults to step * 10. */  largeStep?: number;  /** Optional unit appended to the number, e.g. "px", "%", "seats". */  unit?: string;  /** Format options passed to NumberRoll when not editing. */  format?: Intl.NumberFormatOptions;  /** Visual variant: "pill" for rounded-full, "field" for standard form input. */  variant?: "pill" | "field";  size?: "sm" | "default";  disabled?: boolean;  name?: string;  className?: string;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);function MinusIcon({ className = "size-3.5" }: { className?: string }) {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>      <path d="M3.5 8h9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />    </svg>  );}function PlusIcon({ className = "size-3.5" }: { className?: string }) {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>      <path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />    </svg>  );}const shake = (el: HTMLElement | null, reduced: boolean) => {  if (!el || reduced) return;  el.animate(    [      { transform: "none" },      { transform: "translateX(-3px)" },      { transform: "translateX(3px)" },      { transform: "translateX(-1.5px)" },      { transform: "none" },    ],    { duration: 280, easing: "ease-out", composite: "add" }  );};export function StepperInput({  label,  value: controlledValue,  defaultValue = 0,  onValueChange,  min = -Infinity,  max = Infinity,  step = 1,  largeStep,  unit,  format,  variant = "pill",  size = "default",  disabled = false,  name,  className = "",}: StepperInputProps) {  const reduced = useReducedMotion();  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);  const isControlled = controlledValue !== undefined;  const currentVal = isControlled ? controlledValue : uncontrolledValue;  const [editing, setEditing] = React.useState(false);  const [editText, setEditText] = React.useState(String(currentVal));  const rootRef = React.useRef<HTMLDivElement>(null);  const inputRef = React.useRef<HTMLInputElement>(null);  const holdTimerRef = React.useRef<number | null>(null);  const holdIntervalRef = React.useRef<number | null>(null);  const holdStartRef = React.useRef<number>(0);  const effectiveLargeStep = largeStep ?? step * 10;  const atMin = currentVal <= min;  const atMax = currentVal >= max;  const clamp = React.useCallback(    (n: number) => {      let rounded = Math.round(n / step) * step;      // Fix floating point precision, e.g. 0.1 + 0.2      const stepDecimals = (step.toString().split(".")[1] || "").length;      if (stepDecimals > 0) {        rounded = Number(rounded.toFixed(stepDecimals));      }      return Math.min(max, Math.max(min, rounded));    },    [min, max, step]  );  const updateVal = React.useCallback(    (nextRaw: number) => {      const next = clamp(nextRaw);      if (next === currentVal) {        shake(rootRef.current, reduced);        return false;      }      if (!isControlled) {        setUncontrolledValue(next);      }      onValueChange?.(next);      return true;    },    [clamp, currentVal, isControlled, onValueChange, reduced]  );  const stopHold = React.useCallback(() => {    if (holdTimerRef.current !== null) {      window.clearTimeout(holdTimerRef.current);      holdTimerRef.current = null;    }    if (holdIntervalRef.current !== null) {      window.clearInterval(holdIntervalRef.current);      holdIntervalRef.current = null;    }  }, []);  const stepDelta = React.useCallback(    (dir: 1 | -1, multiplier = 1) => {      const delta = step * multiplier * dir;      return updateVal(currentVal + delta);    },    [currentVal, step, updateVal]  );  const startHold = React.useCallback(    (dir: 1 | -1) => {      if (disabled) return;      stopHold();      // Immediate first step      const canMove = stepDelta(dir, 1);      if (!canMove) return;      holdStartRef.current = Date.now();      // 300ms initial pause before rapid firing      holdTimerRef.current = window.setTimeout(() => {        const tick = () => {          const elapsed = Date.now() - holdStartRef.current;          let mult = 1;          let intervalTime = 90;          if (elapsed > 2400) {            mult = Math.max(1, Math.round(effectiveLargeStep / step));            intervalTime = 30;          } else if (elapsed > 1200) {            mult = Math.max(1, Math.round((effectiveLargeStep / step) / 2));            intervalTime = 55;          }          const ok = stepDelta(dir, mult);          if (!ok) {            stopHold();            return;          }          holdIntervalRef.current = window.setTimeout(tick, intervalTime);        };        tick();      }, 300);    },    [disabled, effectiveLargeStep, step, stepDelta, stopHold]  );  React.useEffect(() => {    return () => stopHold();  }, [stopHold]);  // Sync edit text when value changes from outside while not editing  React.useEffect(() => {    if (!editing) {      setEditText(String(currentVal));    }  }, [currentVal, editing]);  const commitEdit = React.useCallback(() => {    setEditing(false);    const parsed = parseFloat(editText);    if (!Number.isNaN(parsed)) {      updateVal(parsed);    } else {      setEditText(String(currentVal));    }  }, [currentVal, editText, updateVal]);  const handleKeyDown = (e: React.KeyboardEvent) => {    if (disabled) return;    if (e.key === "ArrowUp") {      e.preventDefault();      stepDelta(1, 1);    } else if (e.key === "ArrowDown") {      e.preventDefault();      stepDelta(-1, 1);    } else if (e.key === "PageUp") {      e.preventDefault();      updateVal(currentVal + effectiveLargeStep);    } else if (e.key === "PageDown") {      e.preventDefault();      updateVal(currentVal - effectiveLargeStep);    } else if (e.key === "Home" && min !== -Infinity) {      e.preventDefault();      updateVal(min);    } else if (e.key === "End" && max !== Infinity) {      e.preventDefault();      updateVal(max);    } else if (editing) {      if (e.key === "Enter") {        e.preventDefault();        commitEdit();      } else if (e.key === "Escape") {        e.preventDefault();        setEditing(false);        setEditText(String(currentVal));      }    }  };  const isSmall = size === "sm";  const hCls = isSmall ? "h-7" : "h-9";  const btnW = isSmall ? "w-7" : "w-8";  const roundedCls = variant === "pill" ? "rounded-full" : "rounded-lg";  return (    <div      ref={rootRef}      role="group"      aria-label={label}      tabIndex={disabled ? -1 : 0}      onKeyDown={handleKeyDown}      className={`inline-flex ${hCls} items-center border border-border bg-background p-0.5 select-none ${roundedCls} transition-[box-shadow,border-color] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${disabled ? "opacity-50 pointer-events-none" : ""} ${className}`}      style={{        boxShadow: "0 0 0 1px transparent",      }}    >      {/* Hidden input for HTML form submissions */}      {name && <input type="hidden" name={name} value={currentVal} />}      {/* Decrement Button */}      <button        type="button"        tabIndex={-1}        aria-label={`Decrease ${label}`}        disabled={disabled || atMin}        onPointerDown={(e) => {          if (e.pointerType === "mouse" && e.button !== 0) return;          try {            e.currentTarget.setPointerCapture(e.pointerId);          } catch {}          startHold(-1);        }}        onPointerUp={(e) => {          try {            if (e.currentTarget.hasPointerCapture(e.pointerId)) {              e.currentTarget.releasePointerCapture(e.pointerId);            }          } catch {}          stopHold();        }}        onPointerCancel={stopHold}        className={`flex ${btnW} h-full items-center justify-center ${variant === "pill" ? "rounded-full" : "rounded-md"} text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95 disabled:pointer-events-none disabled:opacity-30 cursor-pointer disabled:cursor-default touch-manipulation`}      >        <MinusIcon />      </button>      {/* Value Display / Direct Edit */}      <div        className="relative flex min-w-[3.25rem] flex-1 items-center justify-center px-2 font-mono text-[13px] tabular-nums text-foreground cursor-text"        onClick={() => {          if (disabled || editing) return;          setEditing(true);          setEditText(String(currentVal));          setTimeout(() => inputRef.current?.select(), 10);        }}      >        {editing ? (          <input            ref={inputRef}            type="text"            inputMode="numeric"            value={editText}            onChange={(e) => setEditText(e.target.value)}            onBlur={commitEdit}            className="w-full bg-transparent text-center font-mono text-[13px] text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"            autoFocus          />        ) : (          <div className="flex items-baseline gap-1" aria-hidden="true">            <NumberRoll value={currentVal} format={format} duration={260} />            {unit && <span className="font-sans text-[11px] text-muted-foreground">{unit}</span>}          </div>        )}        {/* Screen reader live value */}        <span className="sr-only" role="status" aria-live="polite">          {label}: {currentVal} {unit ?? ""}        </span>      </div>      {/* Increment Button */}      <button        type="button"        tabIndex={-1}        aria-label={`Increase ${label}`}        disabled={disabled || atMax}        onPointerDown={(e) => {          if (e.pointerType === "mouse" && e.button !== 0) return;          try {            e.currentTarget.setPointerCapture(e.pointerId);          } catch {}          startHold(1);        }}        onPointerUp={(e) => {          try {            if (e.currentTarget.hasPointerCapture(e.pointerId)) {              e.currentTarget.releasePointerCapture(e.pointerId);            }          } catch {}          stopHold();        }}        onPointerCancel={stopHold}        className={`flex ${btnW} h-full items-center justify-center ${variant === "pill" ? "rounded-full" : "rounded-md"} text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95 disabled:pointer-events-none disabled:opacity-30 cursor-pointer disabled:cursor-default touch-manipulation`}      >        <PlusIcon />      </button>    </div>  );}

Registry item: /r/stepper-input.json