Agents (llms.txt)
Octocat

Number Input

An editable numeric input with stepper buttons, keyboard support, and pluggable formatting for locale-aware display.

import { useState } from 'react';
import { NumberInput } from '@/components/number-input';

export default function NumberInputExample() {
  const [value, setValue] = useState(0);

  return (
    <div className="w-48">
      <NumberInput value={value} onValueChange={setValue} />
    </div>
  );
}

Dependencies

Source Code

import { MinusIcon, PlusIcon } from '@phosphor-icons/react/dist/ssr';
import {
  createContext,
  use,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { Slot } from '@/components/slot';
import {
  Input,
  type InputSize,
  type InputVariant,
} from '@/components/input';
import { cn, cva } from '@/lib/utils/classnames';

interface NumberInputContextValue {
  value: number;
  /** Commit a new value through clamp + step rounding. Returns the committed value. */
  setValue: (next: number) => number;
  increment: (multiplier?: number) => number;
  decrement: (multiplier?: number) => number;
  min: number;
  max: number;
  step: number;
  disabled: boolean;
  size: InputSize;
  format: (n: number) => string;
  parse: (s: string) => number;
}

const NumberInputContext = createContext<NumberInputContextValue | null>(null);

const useNumberInputContext = () => {
  const ctx = use(NumberInputContext);
  if (!ctx) {
    throw new Error(
      'NumberInput components must be used within a <NumberInput />'
    );
  }
  return ctx;
};

const stepPrecision = (step: number): number => {
  const s = String(step);
  const i = s.indexOf('.');
  return i === -1 ? 0 : s.length - i - 1;
};

const round = (n: number, precision: number): number => {
  const f = 10 ** precision;
  return Math.round(n * f) / f;
};

const clamp = (n: number, min: number, max: number): number =>
  Math.min(Math.max(n, min), max);

// Default parser: empty string is invalid (triggers revert), otherwise pass to
// Number — which accepts integers, decimals, scientific notation, and signs.
// Locale-aware parsing is up to the consumer via the `parse` prop.
const defaultParse = (s: string): number => {
  const trimmed = s.trim();
  if (trimmed === '') return Number.NaN;
  return Number(trimmed);
};

interface NumberInputProps
  extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange' | 'children'> {
  /** Controlled value. */
  value?: number;
  /** Default value (uncontrolled). Defaults to `min` if finite, otherwise 0. */
  defaultValue?: number;
  /** Called when the value commits (blur / Enter / stepper / keyboard). */
  onValueChange?: (value: number) => void;
  /** Lower bound (inclusive). Defaults to `-Infinity`. */
  min?: number;
  /** Upper bound (inclusive). Defaults to `+Infinity`. */
  max?: number;
  /** Increment step. Defaults to 1. */
  step?: number;
  disabled?: boolean;
  /** Forwarded to the underlying `Input.Group`. Defaults to `md`. */
  size?: InputSize;
  /** Forwarded to the underlying `Input.Group`. Defaults to `default`. */
  variant?: InputVariant;
  /** Format the committed value for display. Defaults to `String`. */
  format?: (n: number) => string;
  /**
   * Parse the user's typed value. Return `NaN` to reject (the field reverts to
   * the last committed value on blur). Defaults to a `Number`-based parser
   * that treats empty input as invalid.
   */
  parse?: (s: string) => number;
  /**
   * Defaults to `<NumberInput.Decrement /> <NumberInput.Field /> <NumberInput.Increment />`.
   * Pass children to customize the layout (e.g. swap orders, drop a stepper, wrap with Field.Control).
   */
  children?: React.ReactNode;
}

const NumberInput = ({
  value: propsValue,
  defaultValue,
  onValueChange,
  min = Number.NEGATIVE_INFINITY,
  max = Number.POSITIVE_INFINITY,
  step = 1,
  disabled = false,
  size = 'md',
  variant = 'default',
  format = String,
  parse = defaultParse,
  className,
  children,
  ...rest
}: NumberInputProps) => {
  const isControlled = propsValue !== undefined;
  const precision = stepPrecision(step);
  const [internalValue, setInternalValue] = useState<number>(() => {
    const initial = defaultValue ?? (Number.isFinite(min) ? min : 0);
    return round(clamp(initial, min, max), precision);
  });
  const value = isControlled ? propsValue : internalValue;

  const setValue = useCallback(
    (next: number): number => {
      const committed = round(clamp(next, min, max), precision);
      if (!isControlled) setInternalValue(committed);
      onValueChange?.(committed);
      return committed;
    },
    [isControlled, min, max, precision, onValueChange]
  );

  const increment = useCallback(
    (multiplier = 1) => setValue(value + step * multiplier),
    [value, step, setValue]
  );

  const decrement = useCallback(
    (multiplier = 1) => setValue(value - step * multiplier),
    [value, step, setValue]
  );

  const ctx = useMemo<NumberInputContextValue>(
    () => ({
      value,
      setValue,
      increment,
      decrement,
      min,
      max,
      step,
      disabled,
      size,
      format,
      parse,
    }),
    [
      value,
      setValue,
      increment,
      decrement,
      min,
      max,
      step,
      disabled,
      size,
      format,
      parse,
    ]
  );

  return (
    <NumberInputContext value={ctx}>
      <Input.Group
        size={size}
        variant={variant}
        className={className}
        {...rest}
      >
        {children ?? (
          <>
            <NumberInputDecrement />
            <NumberInputField />
            <NumberInputIncrement />
          </>
        )}
      </Input.Group>
    </NumberInputContext>
  );
};

interface NumberInputFieldProps
  extends Omit<
    React.ComponentPropsWithRef<typeof Input>,
    'value' | 'defaultValue' | 'onChange' | 'type' | 'disabled'
  > {}

const NumberInputField = ({
  ref,
  className,
  onBlur,
  onKeyDown,
  ...props
}: NumberInputFieldProps) => {
  const { value, setValue, min, max, step, disabled, format, parse } =
    useNumberInputContext();

  const [draft, setDraft] = useState<string>(() => format(value));

  // Tracks whether the user has typed since the last commit. Gates the sync
  // effect below so an inline `format` arrow on a parent re-render can't stomp
  // typed-but-uncommitted text. Reset by every commit / step / jump path.
  const dirtyRef = useRef(false);

  // Mirror the committed value into the displayed draft. We can't simply
  // depend on `value` alone — `format` may also need to re-run on a locale
  // switch — but we must skip the sync while the user is mid-edit.
  useEffect(() => {
    if (!dirtyRef.current) setDraft(format(value));
  }, [value, format]);

  const stepBy = (multiplier: number) => {
    const parsed = parse(draft);
    const base = Number.isFinite(parsed) ? parsed : value;
    const committed = setValue(base + step * multiplier);
    setDraft(format(committed));
    dirtyRef.current = false;
  };

  const jumpTo = (target: number) => {
    const committed = setValue(target);
    setDraft(format(committed));
    dirtyRef.current = false;
  };

  const commit = () => {
    const parsed = parse(draft);
    if (Number.isFinite(parsed)) {
      const committed = setValue(parsed);
      setDraft(format(committed));
    } else {
      setDraft(format(value));
    }
    dirtyRef.current = false;
  };

  return (
    <Input
      ref={ref}
      type="text"
      inputMode="decimal"
      role="spinbutton"
      aria-valuenow={value}
      aria-valuetext={format(value)}
      aria-valuemin={Number.isFinite(min) ? min : undefined}
      aria-valuemax={Number.isFinite(max) ? max : undefined}
      value={draft}
      disabled={disabled}
      onChange={(e) => {
        setDraft(e.target.value);
        dirtyRef.current = true;
      }}
      onBlur={(e) => {
        commit();
        onBlur?.(e);
      }}
      onKeyDown={(e) => {
        onKeyDown?.(e);
        if (e.defaultPrevented) return;

        if (e.key === 'Enter') {
          commit();
          return;
        }
        if (e.key === 'ArrowUp') {
          e.preventDefault();
          stepBy(e.shiftKey ? 10 : 1);
          return;
        }
        if (e.key === 'ArrowDown') {
          e.preventDefault();
          stepBy(e.shiftKey ? -10 : -1);
          return;
        }
        if (e.key === 'PageUp') {
          e.preventDefault();
          stepBy(10);
          return;
        }
        if (e.key === 'PageDown') {
          e.preventDefault();
          stepBy(-10);
          return;
        }
        if (e.key === 'Home' && Number.isFinite(min)) {
          e.preventDefault();
          jumpTo(min);
          return;
        }
        if (e.key === 'End' && Number.isFinite(max)) {
          e.preventDefault();
          jumpTo(max);
        }
      }}
      className={cn('text-center tabular-nums', className)}
      {...props}
    />
  );
};

// xs/sm groups have no `py`, so the stepper can't inset vertically — it runs
// flush and side-rounds (`rounded-l-lg` / `rounded-r-lg`) to trace the group's
// outer curve at the edge. md/lg groups have `py-1`, so the stepper insets
// horizontally with `mx-(--inset)` and rounds with the next-smaller token —
// `rounded-{lg,xl}`. The radius scale is calibrated so each step equals
// `--inset` (`--radius * 2`), keeping corners concentric across the dial.
const stepperStyle = cva({
  base: 'focus-visible:ring-(length:--ring-width) inline-flex aspect-square h-full shrink-0 cursor-pointer items-center justify-center self-stretch text-foreground-secondary outline-none ring-ring transition-colors hover:bg-foreground/5 hover:text-foreground disabled:pointer-events-none disabled:opacity-40',
  variants: {
    size: {
      xs: '',
      sm: '',
      md: 'mx-(--inset) rounded-lg',
      lg: 'mx-(--inset) rounded-xl',
    },
    side: {
      decrement: '',
      increment: '',
    },
  },
  compoundVariants: [
    { size: 'xs', side: 'decrement', class: 'rounded-l-lg' },
    { size: 'xs', side: 'increment', class: 'rounded-r-lg' },
    { size: 'sm', side: 'decrement', class: 'rounded-l-lg' },
    { size: 'sm', side: 'increment', class: 'rounded-r-lg' },
  ],
  defaultVariants: {
    size: 'md',
    side: 'decrement',
  },
});

interface NumberInputStepperProps
  extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

const NumberInputDecrement = ({
  ref,
  asChild,
  className,
  onClick,
  disabled: propDisabled,
  'aria-label': ariaLabel = 'Decrement',
  children,
  ...props
}: NumberInputStepperProps) => {
  const { decrement, value, min, disabled, size } = useNumberInputContext();
  const isAtMin = Number.isFinite(min) && value <= min;
  const isDisabled = propDisabled || disabled || isAtMin;
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      ref={ref}
      type={asChild ? undefined : 'button'}
      aria-label={ariaLabel}
      disabled={isDisabled}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        decrement();
      }}
      className={cn(
        !asChild && stepperStyle({ size, side: 'decrement' }),
        className
      )}
      {...props}
    >
      {children ?? <MinusIcon className="size-4" />}
    </Comp>
  );
};

const NumberInputIncrement = ({
  ref,
  asChild,
  className,
  onClick,
  disabled: propDisabled,
  'aria-label': ariaLabel = 'Increment',
  children,
  ...props
}: NumberInputStepperProps) => {
  const { increment, value, max, disabled, size } = useNumberInputContext();
  const isAtMax = Number.isFinite(max) && value >= max;
  const isDisabled = propDisabled || disabled || isAtMax;
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      ref={ref}
      type={asChild ? undefined : 'button'}
      aria-label={ariaLabel}
      disabled={isDisabled}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        increment();
      }}
      className={cn(
        !asChild && stepperStyle({ size, side: 'increment' }),
        className
      )}
      {...props}
    >
      {children ?? <PlusIcon className="size-4" />}
    </Comp>
  );
};

const CompoundNumberInput = Object.assign(NumberInput, {
  Field: NumberInputField,
  Decrement: NumberInputDecrement,
  Increment: NumberInputIncrement,
});

export type { NumberInputProps };
export { CompoundNumberInput as NumberInput, useNumberInputContext };

Anatomy


          // Default — root renders an Input.Group with steppers + field.
<NumberInput value={n} onValueChange={setN} min={0} max={100} step={1} />

// Custom layout — pass children to override.
<NumberInput value={n} onValueChange={setN}>
  <NumberInput.Decrement />
  <NumberInput.Field />
  <NumberInput.Increment />
</NumberInput>
        

The root wraps everything in Input.Group and provides a default layout. Pass children when you need a different arrangement — drop a stepper, wrap the field with Field.Control, or splice in custom addons.

API Reference

NumberInput

Prop Default Type Description
value - number Controlled value.
defaultValue - number Default value (uncontrolled). Defaults to `min` if finite, otherwise `0`.
onValueChange - (value: number) => void Called when the value commits — on blur, Enter, stepper click, or keyboard step.
min -Infinity number Lower bound, inclusive. Steppers disable at the bound only when finite.
max +Infinity number Upper bound, inclusive.
step 1 number Increment step. Determines display precision (e.g. `step={0.01}` rounds to 2 decimals).
disabled false boolean
size "md" "xs""sm""md""lg" Forwarded to the underlying `Input.Group`.
variant "default" "default""minimal" Forwarded to the underlying `Input.Group`.
format String (n: number) => string Format the committed value for display.
parse - (s: string) => number Parse the user's typed value. Return `NaN` to reject (the field reverts on blur). Defaults to a `Number`-based parser that treats empty input as invalid.

NumberInput.Field

Extends Input. Renders an <input type="text" inputMode="decimal" role="spinbutton"> wired to the context. value, defaultValue, onChange, type, and disabled are owned by the root and can’t be overridden here.

NumberInput.Decrement / NumberInput.Increment

Extend the button element. Auto-disable when the value reaches a finite min / max. Default to a Phosphor Minus / Plus icon — pass children to override, or asChild to swap the entire element.

Behavior

Typing is unrestricted

The field accepts any input — digits, signs, ., ,, e, even garbage like abc. There is no per-keystroke filtering. The user’s input is parsed only on commit (blur or Enter):

  • If parse returns a finite number, the value clamps to [min, max], rounds to step precision, and commits.
  • If parse returns NaN, the field reverts to the last committed value.

This matches the spirit of native <input type="number"> (which also accepts e for scientific notation) and avoids fighting the user mid-edit.

Keyboard

KeyAction
/ Step by ±step
Shift + ↑/↓Step by ±step × 10
Page Up/DownStep by ±step × 10
HomeJump to min (if finite)
EndJump to max (if finite)
EnterCommit the current draft

Bounded vs unbounded

min and max default to ±Infinity. Bounds only become enforced (and steppers disable) when finite. Pass any combination — bounded above, bounded below, or both.

0–100, step 5. Steppers disable at the bounds.

import { useState } from 'react';
import { NumberInput } from '@/components/number-input';

export default function NumberInputBoundedExample() {
  const [value, setValue] = useState(50);

  return (
    <div className="flex w-56 flex-col gap-2">
      <NumberInput
        value={value}
        onValueChange={setValue}
        min={0}
        max={100}
        step={5}
      />
      <p className="text-foreground-secondary text-xs">
        0–100, step 5. Steppers disable at the bounds.
      </p>
    </div>
  );
}

Sizes

import { useState } from 'react';
import { NumberInput } from '@/components/number-input';

const sizes = ['xs', 'sm', 'md', 'lg'] as const;

export default function NumberInputSizesExample() {
  const [value, setValue] = useState(0);

  return (
    <div className="flex w-56 flex-col gap-3">
      {sizes.map((size) => (
        <NumberInput
          key={size}
          size={size}
          value={value}
          onValueChange={setValue}
          min={0}
          max={100}
        />
      ))}
    </div>
  );
}

Inside a Field

Field.Control wraps a single form control. Place it around NumberInput.Field, not the root — the root has no DOM and the steppers aren’t form controls.

Between 1 and 99. Use ↑/↓ to step, Shift for ×10.

import { useState } from 'react';
import { Field } from '@/components/field';
import { NumberInput } from '@/components/number-input';

export default function NumberInputFormExample() {
  const [quantity, setQuantity] = useState(1);

  return (
    <div className="w-64">
      <Field>
        <Field.Label>Quantity</Field.Label>
        <NumberInput
          value={quantity}
          onValueChange={setQuantity}
          min={1}
          max={99}
        >
          <NumberInput.Decrement />
          <Field.Control>
            <NumberInput.Field />
          </Field.Control>
          <NumberInput.Increment />
        </NumberInput>
        <Field.Description>
          Between 1 and 99. Use ↑/↓ to step, Shift for ×10.
        </Field.Description>
      </Field>
    </div>
  );
}

Locale-aware formatting

Pass format and parse to swap how the value is shown and how user input is interpreted. Anything goes — Intl.NumberFormat, custom precision, currency, thousands separators. This example uses de-DE formatting (1.234,56):

import { useMemo, useState } from 'react';
import { NumberInput } from '@/components/number-input';

// German locale formatting: `1.234,56` instead of `1,234.56`. The same pattern
// works for any locale via `Intl.NumberFormat`.
export default function NumberInputLocaleExample() {
  const [value, setValue] = useState(1234.56);

  const { format, parse } = useMemo(() => {
    const formatter = new Intl.NumberFormat('de-DE', {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    });
    return {
      format: (n: number) => formatter.format(n),
      parse: (s: string) => {
        const trimmed = s.trim();
        if (trimmed === '') return Number.NaN;
        // Strip thousands separators (`.`), normalize decimal `,` to `.`.
        const normalized = trimmed.replace(/\./g, '').replace(',', '.');
        return Number(normalized);
      },
    };
  }, []);

  return (
    <div className="w-64">
      <NumberInput
        value={value}
        onValueChange={setValue}
        step={0.01}
        format={format}
        parse={parse}
      />
    </div>
  );
}

          const formatter = new Intl.NumberFormat("de-DE", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

<NumberInput
  format={(n) => formatter.format(n)}
  parse={(s) => Number(s.replace(/\./g, "").replace(",", "."))}
  // ...
/>;
        

Async sync

Common shape for ecommerce-style flows: commit locally on every change so the UI feels instant, debounce the server call in the background, and surface a status indicator as a sibling of the input. Keep the field interactive throughout — disabling mid-sync makes a snappy interaction feel laggy. Avoid placing the indicator inside the input next to a centered value; it competes with the optical balance.

import { useEffect, useMemo, useRef, useState } from 'react';
import { NumberInput } from '@/components/number-input';
import { Spinner } from '@/components/spinner';
import { debounce } from '@/lib/debounce';
import { cn } from '@/lib/utils/classnames';

// Cart-style quantity selector: optimistic local update on every commit,
// debounced server sync in the background. The field stays interactive while
// in-flight — disabling mid-sync would feel laggy. The status indicator lives
// outside the input so the field's centered value stays optically balanced.
export default function NumberInputAsyncExample() {
  const [quantity, setQuantity] = useState(1);
  const [syncing, setSyncing] = useState(false);
  const inFlight = useRef(0);

  const sync = useMemo(
    () =>
      debounce(async (_next: number) => {
        const id = ++inFlight.current;
        setSyncing(true);
        // Pretend to hit a server.
        await new Promise((r) => setTimeout(r, 800));
        // Only clear if this is the latest call.
        if (id === inFlight.current) setSyncing(false);
      }, 400),
    []
  );

  useEffect(() => {
    sync(quantity);
  }, [quantity, sync]);

  return (
    <div className="flex items-center gap-3">
      <div className="w-48">
        <NumberInput
          value={quantity}
          onValueChange={setQuantity}
          min={1}
          max={99}
        />
      </div>
      <Spinner
        size="sm"
        className={cn(
          'transition-opacity',
          syncing ? 'opacity-100' : 'opacity-0'
        )}
      />
    </div>
  );
}

Best Practices

  1. Don’t fight the user. No keystroke filtering, no auto-format mid-edit. Validate on blur. If a user wants to paste 1e3, let them — it’s a valid number.
  2. Use step for precision, not decoration. Setting step={0.01} doesn’t just constrain the steppers — it also rounds committed values to 2 decimals. Pick a step that reflects the precision you actually want stored.
  3. Bounded inputs deserve labels. When min/max are enforced, communicate them: a Field.Description (“Between 1 and 99”) goes a long way for users who don’t notice the steppers disabling.
  4. Accessibility:
    • The field renders role="spinbutton" with aria-valuenow, aria-valuetext, and (when finite) aria-valuemin / aria-valuemax.
    • aria-valuetext is the formatted string ("1.234,56"), so screen readers announce the displayed value, not the raw number.
    • Steppers ship with sensible default aria-labels — override per locale.

Previous

Modal

Next

OTP Input