bigbullui logobigbullui
Componentform

Stepper

Increment and decrement buttons with mono readout.

Preview

Live Preview
2

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add stepper

Or install the full npm package:

npm install bigbullui

Usage

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

<Stepper defaultValue={1} min={0} max={10} />

Source code

stepper.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

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

export function Stepper({
  value: controlledValue,
  defaultValue = 0,
  min = 0,
  max = 100,
  step = 1,
  onValueChange,
  disabled = false,
  className,
  "aria-label": ariaLabel = "Stepper",
}: StepperProps) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const currentVal = isControlled ? controlledValue : uncontrolledValue;

  const clamp = React.useCallback(
    (val: number) => Math.min(Math.max(val, min), max),
    [min, max]
  );

  const updateValue = (nextVal: number) => {
    const clamped = clamp(nextVal);
    if (!isControlled) {
      setUncontrolledValue(clamped);
    }
    onValueChange?.(clamped);
  };

  const handleDecrement = () => {
    if (disabled || currentVal <= min) return;
    updateValue(currentVal - step);
  };

  const handleIncrement = () => {
    if (disabled || currentVal >= max) return;
    updateValue(currentVal + step);
  };

  const isMin = currentVal <= min;
  const isMax = currentVal >= max;

  return (
    <div
      role="group"
      aria-label={ariaLabel}
      className={cn(
        "inline-flex items-center rounded-md border-2 border-dashed border-border bg-card p-1",
        disabled && "opacity-50",
        className
      )}
    >
      <button
        type="button"
        disabled={disabled || isMin}
        onClick={handleDecrement}
        aria-label="Decrease value"
        className={cn(
          "flex size-8 items-center justify-center rounded-sm border border-border bg-secondary font-mono text-sm font-bold transition-all",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          isMin || disabled
            ? "cursor-not-allowed opacity-40"
            : "cursor-pointer hover:bg-primary hover:text-primary-foreground active:scale-95"
        )}
      >
        −
      </button>

      <span
        aria-live="polite"
        className="min-w-12 px-3 text-center font-mono text-sm font-semibold tracking-wider text-foreground"
      >
        {currentVal}
      </span>

      <button
        type="button"
        disabled={disabled || isMax}
        onClick={handleIncrement}
        aria-label="Increase value"
        className={cn(
          "flex size-8 items-center justify-center rounded-sm border border-border bg-secondary font-mono text-sm font-bold transition-all",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          isMax || disabled
            ? "cursor-not-allowed opacity-40"
            : "cursor-pointer hover:bg-primary hover:text-primary-foreground active:scale-95"
        )}
      >
        +
      </button>
    </div>
  );
}

Props

PropTypeDescription
valuenumberControlled value.
defaultValuenumberInitial value (default 0).
minnumberMinimum allowed value (default 0).
maxnumberMaximum allowed value (default 100).
stepnumberStep increment (default 1).
onValueChange(value: number) => voidCallback when value changes.
disabledbooleanDisables stepper interaction.