FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Number Input

Number Input

pnpm dlx shadcn@latest add …/r/number-input.json

An accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.

  • 1
    Quantity
  • $24.00
    Price
import { NumberInput } from "@/components/ui/number-input"

export function Quantity() {
  return (
    <>
      <label htmlFor="quantity">Quantity</label>
      <NumberInput id="quantity" defaultValue={1} min={1} max={99} />
    </>
  )
}
fields.tsx
"use client";

import { NumberInput } from "@/components/ui/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";

type Size = "sm" | "md" | "lg";

/*
 * A quantity and a price side by side, named underneath, with the size in the top-right corner. An empty
 * row at the bottom, as tall as the setting's row, keeps them centred.
 */
export default function NumberInputFields() {
  const id = useId();
  const [size, setSize] = useState<Size>("md");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
          Size
        </label>
        <Select value={size} onValueChange={(value) => setSize(value as Size)}>
          <SelectTrigger id={`${id}-size`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="sm">Small</SelectItem>
            <SelectItem value="md">Medium</SelectItem>
            <SelectItem value="lg">Large</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <ul className="flex flex-1 flex-wrap items-center justify-center gap-x-10 gap-y-6">
        <li className="flex flex-col items-center gap-2">
          <NumberInput aria-label="Quantity" defaultValue={1} min={1} max={99} size={size} className="w-32" />
          <span className="text-xs text-muted-foreground">Quantity</span>
        </li>
        <li className="flex flex-col items-center gap-2">
          <NumberInput
            aria-label="Price"
            defaultValue={24}
            min={0}
            step={0.5}
            largeStep={10}
            locale="en-US"
            format={{ style: "currency", currency: "USD" }}
            size={size}
            className="w-40"
          />
          <span className="text-xs text-muted-foreground">Price</span>
        </li>
      </ul>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority lucide-react motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Button first.

  5. 5

    Copy the source into your project.

    components/ui/number-input.tsx
    "use client";
    
    import { Button } from "@/components/ui/button";
    import { blur, duration, ease, spring, swap } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import { MinusIcon, PlusIcon } from "lucide-react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import {
      type ComponentProps,
      type KeyboardEvent,
      type MouseEvent,
      type PointerEvent,
      useEffect,
      useId,
      useRef,
      useState,
    } from "react";
    
    /*
     * Number Input: a number field with step buttons (owner, 2026-10-16). It follows the WAI-ARIA spinbutton
     * pattern: a text input with role="spinbutton", ArrowUp/ArrowDown step, PageUp/PageDown take a big step,
     * Home/End go to min/max. Typed text is read when you leave the field or press Enter, then clamped and
     * formatted; a value in range also updates as you type.
     * - Motion (.agents/rules/motion.md): when a step changes the number, it rolls like an odometer. Each digit
     *   that changed rolls a whole line: the old one travels out of the field as the new one travels in from the
     *   other edge (up for up, down for down) on spring.snappy, sharpening through the blur over duration.slow so
     *   the roll reads smooth rather than flickering (owner, 2026-10-21: "more noticeable"). Places set off 0.05s
     *   apart from the right, so 19 → 20 turns the ones, then the tens. The field's top and bottom edges fade, so
     *   digits roll in and out of sight softly. The others stay put; typing doesn't animate. While it rolls, the
     *   input's own text is transparent and a copy is drawn over it, character by character, with tabular figures
     *   so nothing jiggles; then the input's text shows again. With reduced motion the number just changes.
     * - The step buttons are our ghost icon Buttons inside the field. They aren't in the tab order (the keys do
     *   the same) and don't take focus. Press and hold steps once, then repeats.
     * - Formatting is optional, through Intl.NumberFormat (`format`, `locale`). A form gets the plain number
     *   through a hidden input.
     * - The field looks like Input: control heights, Input's border, its focus ring around the whole field.
     */
    
    /** Press and hold: one step at once, then after `duration.slower`, one every `duration.instant` */
    const HOLD_DELAY = duration.slower * 1000;
    const HOLD_REPEAT = duration.instant * 1000;
    /** Places set off one after another from the right: motion.md's list stagger */
    const STAGGER = 0.05;
    /**
     * After this long without another step, the rolling copy hands back to the input's own text: the roll's
     * blur (duration.slow) plus room for the spring to land and a few places' stagger
     */
    const ROLL_MS = duration.slow * 2 * 1000;
    /** The roller's top and bottom edges fade, so digits roll in and out of sight */
    const ROLL_FADE = "linear-gradient(to bottom, transparent, black 30%, black 70%, transparent)";
    
    const numberInputVariants = cva(
      [
        "relative flex w-full min-w-0 items-center rounded-lg border border-input bg-transparent px-1 text-sm text-foreground shadow-xs",
        "transition duration-(--duration-fast) ease-out",
        // The ring goes round the whole field while its input has focus, the way Input's does
        "outline-ring has-focus-visible:border-ring has-focus-visible:outline-(length:--focus-ring-width) has-focus-visible:outline-offset-(--focus-ring-offset)",
        "data-invalid:border-destructive data-invalid:outline-destructive",
        "data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)",
      ],
      {
        variants: {
          size: {
            sm: "h-(--size-control-sm) rounded-md",
            md: "h-(--size-control-md)",
            lg: "h-(--size-control-lg)",
          },
        },
        defaultVariants: { size: "md" },
      },
    );
    
    // The buttons sit 3px inside the field's border at every size
    const buttonSizes = { sm: "size-6", md: "size-7", lg: "size-8" } as const;
    const iconSizes = { sm: "size-(--size-icon-sm)", md: "size-(--size-icon-md)", lg: "size-(--size-icon-md)" };
    
    /** Decimal places in a number, so 0.1 + 0.2 steps to 0.3 */
    function decimalsOf(value: number) {
      const [, fraction = "", exponent] = /^-?\d*\.?(\d*)(?:e([+-]\d+))?$/.exec(String(value)) ?? [];
      return Math.max(0, fraction.length - Number(exponent ?? 0));
    }
    
    /**
     * Reads typed text in the field's locale: group separators, currency signs and spaces are ignored. Empty
     * text is null, and text with no digits is NaN (so the field goes back to the last number).
     */
    function parseNumber(text: string, locale: string | undefined, format: Intl.NumberFormatOptions | undefined) {
      const trimmed = text.trim();
      if (!trimmed) return null;
      // Without a format the field shows plain numbers (1234.5), so it reads them the same way
      const parts = new Intl.NumberFormat(format ? locale : "en-US").formatToParts(-12345.6);
      const group = parts.find((part) => part.type === "group")?.value ?? ",";
      const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
      // A minus sign anywhere (-$5, $-5, 5-, the typographic −) or accounting brackets, (5.00), is negative
      const negative = /[-−]/.test(trimmed) || /^\(.*\)$/.test(trimmed);
      const digits = trimmed
        .split(group)
        .join("")
        .split(decimal)
        .join(".")
        .replace(/[^\d.]/g, "");
      if (!/\d/.test(digits)) return Number.NaN;
      let number = Number(digits);
      if (!Number.isFinite(number)) return Number.NaN;
      if (negative) number = -number;
      // A percent field shows 25% for 0.25
      if (format?.style === "percent") number /= 100;
      return number;
    }
    
    type Roll = { direction: 1 | -1; animate: boolean };
    
    /** A digit at rest, and one a whole line above or below it, blurred */
    const rest = { y: "0%", opacity: 1, filter: blur.shown };
    const away = (side: 1 | -1) => ({ y: `${side * 100}%`, opacity: 0, filter: blur.hidden });
    
    /** Going up, the new digit comes from below and the old one leaves upwards; going down, the other way */
    const rollVariants = {
      enter: ({ direction, animate }: Roll) => (animate ? away(direction) : swap.animate),
      center: rest,
      exit: ({ direction, animate }: Roll) =>
        animate
          ? // The element's own transition: the old digit leaves with the same stagger the new one arrives on
            away(direction === 1 ? -1 : 1)
          : { opacity: 0, transition: { duration: 0 } },
    };
    /** The roll for a place: the travel on spring.snappy, the blur and fade on a smooth duration.slow, staggered */
    const rollTransition = (place: number) => {
      const delay = Math.max(0, place) * STAGGER;
      return { duration: duration.slow, ease: ease.out, delay, y: { ...spring.snappy, delay } };
    };
    
    /**
     * The rolling copy of the field's text. Places are counted from the right, so 19 → 20 rolls both digits
     * and 20 → 21 only the last. Each place stacks the old and new character in one cell while they swap.
     */
    function Roller({ text, roll }: { text: string; roll: Roll | null }) {
      const custom = roll ?? { direction: 1, animate: false };
      const chars = Array.from(text);
      return (
        <span
          aria-hidden
          data-slot="number-input-roll"
          className={cn(
            "pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden px-2 tabular-nums",
            !roll && "invisible",
          )}
          style={{ maskImage: ROLL_FADE, WebkitMaskImage: ROLL_FADE }}
        >
          {/* A place that goes (21 → 9) pops out of the row so the rest settle at once */}
          <AnimatePresence initial={false} mode="popLayout" custom={custom}>
            {chars.map((char, index) => {
              const place = chars.length - 1 - index;
              return (
                <motion.span
                  key={place}
                  custom={custom}
                  variants={rollVariants}
                  initial="enter"
                  animate="center"
                  exit="exit"
                  transition={rollTransition(place)}
                  className="inline-grid"
                >
                  <AnimatePresence initial={false} custom={custom}>
                    <motion.span
                      key={char}
                      custom={custom}
                      variants={rollVariants}
                      initial="enter"
                      animate="center"
                      exit="exit"
                      transition={rollTransition(place)}
                      className="col-start-1 row-start-1 whitespace-pre"
                    >
                      {char}
                    </motion.span>
                  </AnimatePresence>
                </motion.span>
              );
            })}
          </AnimatePresence>
        </span>
      );
    }
    
    export type NumberInputProps = Omit<
      ComponentProps<"input">,
      "value" | "defaultValue" | "onChange" | "type" | "size" | "min" | "max" | "step"
    > &
      VariantProps<typeof numberInputVariants> & {
        /** The number, when you control it; null when the field is empty */
        value?: number | null;
        /** The number at first */
        defaultValue?: number | null;
        /** Called with the new number, or null when the field is emptied */
        onValueChange?: (value: number | null) => void;
        /** The lowest number; Home goes here */
        min?: number;
        /** The highest number; End goes here */
        max?: number;
        /** How much ArrowUp, ArrowDown and the buttons change it */
        step?: number;
        /** How much PageUp and PageDown change it; ten steps if left out */
        largeStep?: number;
        /** Intl.NumberFormat options, for example { style: "currency", currency: "USD" } */
        format?: Intl.NumberFormatOptions;
        /** The locale to format and read numbers in; the browser's if left out */
        locale?: string;
        /** The increment button's accessible name */
        incrementLabel?: string;
        /** The decrement button's accessible name */
        decrementLabel?: string;
      };
    
    /**
     * A number field with step buttons. `className` styles the field; other props, including `id` and `ref`,
     * go to its input. Give it a visible label (`<label htmlFor>`).
     */
    function NumberInput({
      className,
      size,
      value,
      defaultValue,
      onValueChange,
      min,
      max,
      step = 1,
      largeStep,
      format,
      locale,
      incrementLabel = "Increase",
      decrementLabel = "Decrease",
      id,
      name,
      form,
      disabled = false,
      readOnly = false,
      "aria-invalid": ariaInvalid,
      onKeyDown,
      onBlur,
      ...props
    }: NumberInputProps) {
      const fallbackId = useId();
      const inputId = id ?? fallbackId;
      // Mirror the value, controlled or not, so stepping and the buttons always start from the latest number
      const [uncontrolled, setUncontrolled] = useState<number | null>(defaultValue ?? null);
      const current = value === undefined ? uncontrolled : value;
      // The text while the user types; null shows the formatted number
      const [draft, setDraft] = useState<string | null>(null);
      const [roll, setRoll] = useState<{ direction: 1 | -1; count: number } | null>(null);
      const reduce = useReducedMotion() ?? false;
      const latest = useRef(current);
      latest.current = current;
    
      const formatter = format ? new Intl.NumberFormat(locale, format) : null;
      const display = (number: number | null) =>
        number === null ? "" : formatter ? formatter.format(number) : String(number);
      const text = draft ?? display(current);
      const clamp = (number: number) =>
        Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, number));
      const invalid = ariaInvalid === true || ariaInvalid === "true";
    
      function change(next: number | null) {
        if (Object.is(next, latest.current)) return;
        latest.current = next;
        if (value === undefined) setUncontrolled(next);
        onValueChange?.(next);
      }
    
      /** A change made by stepping, which rolls the number. Returns false when nothing changed (at a bound). */
      function stepTo(next: number, direction: 1 | -1) {
        setDraft(null);
        if (next === latest.current) return false;
        if (!reduce) setRoll((last) => ({ direction, count: (last?.count ?? 0) + 1 }));
        change(next);
        return true;
      }
    
      function stepBy(direction: 1 | -1, amount = step) {
        if (disabled || readOnly) return false;
        const typed = draft === null ? null : parseNumber(draft, locale, format);
        const base = typed === null || Number.isNaN(typed) ? latest.current : typed;
        // From empty, the first step shows 0 (or the nearest bound)
        if (base === null) return stepTo(clamp(0), direction);
        const places = Math.max(decimalsOf(base), decimalsOf(amount));
        return stepTo(clamp(Number((base + direction * amount).toFixed(places))), direction);
      }
    
      function goTo(target: number) {
        if (disabled || readOnly) return;
        const from = latest.current ?? target;
        stepTo(target, target >= from ? 1 : -1);
      }
    
      /** Reads what was typed: clamped and formatted, or back to the last number when it isn't one */
      function commit() {
        if (draft === null) return;
        const typed = parseNumber(draft, locale, format);
        setDraft(null);
        if (typed === null) change(null);
        else if (!Number.isNaN(typed)) change(clamp(typed));
      }
    
      // The rolling copy hands back to the input's own text once the steps stop
      useEffect(() => {
        if (!roll) return;
        const timer = setTimeout(() => setRoll(null), ROLL_MS);
        return () => clearTimeout(timer);
      }, [roll]);
    
      // Press and hold. The timers call the latest stepBy, so each repeat starts from the latest number.
      const stepRef = useRef(stepBy);
      stepRef.current = stepBy;
      const hold = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
      const pointerStep = useRef(false);
      const stopHold = () => clearTimeout(hold.current);
      useEffect(() => () => clearTimeout(hold.current), []);
    
      function startHold(direction: 1 | -1) {
        stopHold();
        if (!stepRef.current(direction)) return;
        const repeat = (wait: number) => {
          hold.current = setTimeout(() => {
            if (stepRef.current(direction)) repeat(HOLD_REPEAT);
          }, wait);
        };
        repeat(HOLD_DELAY);
      }
    
      const atMin = current !== null && min !== undefined && current <= min;
      const atMax = current !== null && max !== undefined && current >= max;
    
      const stepButton = (direction: 1 | -1) => ({
        variant: "ghost" as const,
        size: "icon-sm" as const,
        // The keys do the same, so the buttons stay out of the tab order (WAI-ARIA spinbutton)
        tabIndex: -1,
        "aria-label": direction > 0 ? incrementLabel : decrementLabel,
        "aria-controls": inputId,
        disabled: disabled || readOnly || (direction > 0 ? atMax : atMin),
        className: cn("text-muted-foreground hover:text-foreground", buttonSizes[size ?? "md"]),
        // Pressing doesn't take focus from the input
        onMouseDown: (event: MouseEvent<HTMLButtonElement>) => event.preventDefault(),
        onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {
          if (event.button !== 0) return;
          event.preventDefault();
          pointerStep.current = true;
          startHold(direction);
        },
        onPointerUp: stopHold,
        onPointerLeave: stopHold,
        onPointerCancel: stopHold,
        onClick: (event: MouseEvent<HTMLButtonElement>) => {
          // A pointer already stepped on press; a click from the keyboard or a screen reader steps here
          if (event.detail === 0 || !pointerStep.current) stepRef.current(direction);
          pointerStep.current = false;
        },
      });
    
      return (
        <div
          data-slot="number-input"
          data-size={size ?? "md"}
          data-invalid={invalid || undefined}
          data-disabled={disabled || undefined}
          className={cn(numberInputVariants({ size }), className)}
        >
          <Button {...stepButton(-1)}>
            <MinusIcon aria-hidden className={iconSizes[size ?? "md"]} />
          </Button>
          <div className="relative h-full min-w-0 flex-1">
            <input
              id={inputId}
              type="text"
              role="spinbutton"
              inputMode="decimal"
              autoComplete="off"
              autoCorrect="off"
              spellCheck={false}
              aria-valuenow={current ?? undefined}
              aria-valuemin={min}
              aria-valuemax={max}
              aria-valuetext={formatter && current !== null ? formatter.format(current) : undefined}
              aria-invalid={ariaInvalid}
              disabled={disabled}
              readOnly={readOnly}
              data-slot="number-input-input"
              value={text}
              onChange={(event) => {
                const next = event.target.value;
                setDraft(next);
                setRoll(null);
                // A number in range updates as you type; anything else waits until you leave the field
                const typed = parseNumber(next, locale, format);
                if (typed === null) change(null);
                else if (!Number.isNaN(typed) && typed === clamp(typed)) change(typed);
              }}
              onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
                onKeyDown?.(event);
                if (event.defaultPrevented || disabled || readOnly) return;
                const big = largeStep ?? step * 10;
                switch (event.key) {
                  case "ArrowUp":
                    stepBy(1);
                    break;
                  case "ArrowDown":
                    stepBy(-1);
                    break;
                  case "PageUp":
                    stepBy(1, big);
                    break;
                  case "PageDown":
                    stepBy(-1, big);
                    break;
                  case "Home":
                    if (min === undefined) return;
                    goTo(min);
                    break;
                  case "End":
                    if (max === undefined) return;
                    goTo(max);
                    break;
                  case "Enter":
                    // Read the typed number before the form submits
                    commit();
                    return;
                  default:
                    return;
                }
                event.preventDefault();
              }}
              onBlur={(event) => {
                commit();
                onBlur?.(event);
              }}
              className={cn(
                "size-full min-w-0 bg-transparent px-2 text-center tabular-nums outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed",
                roll && !reduce && "text-transparent",
              )}
              {...props}
            />
            <Roller text={text} roll={roll && !reduce ? { direction: roll.direction, animate: true } : null} />
          </div>
          <Button {...stepButton(1)}>
            <PlusIcon aria-hidden className={iconSizes[size ?? "md"]} />
          </Button>
          {name ? (
            <input type="hidden" name={name} form={form} value={current ?? ""} disabled={disabled} />
          ) : null}
        </div>
      );
    }
    
    export { NumberInput, numberInputVariants };
  6. 6

    Update the import paths to match your project.

Examples

Percent

A value from 0 to 1 shown as a percent, in steps of 5%. Page Up and Page Down take bigger steps; Home and End go to the ends.

15%

Up to 100%, in steps of 5%.

percent.tsx
import { NumberInput } from "@/components/ui/number-input";
import { useId } from "react";

/** A percent between 0 and 1 in steps of 5%. Home and End go to 0% and 100%. */
export default function NumberInputPercent() {
  const id = useId();
  return (
    <div className="grid w-full max-w-xs gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Discount
      </label>
      <NumberInput
        id={id}
        defaultValue={0.15}
        min={0}
        max={1}
        step={0.05}
        largeStep={0.25}
        locale="en-US"
        format={{ style: "percent" }}
        aria-describedby={`${id}-hint`}
      />
      <p id={`${id}-hint`} className="text-sm text-muted-foreground">
        Up to 100%, in steps of 5%.
      </p>
    </div>
  );
}

Disabled

5
disabled.tsx
import { NumberInput } from "@/components/ui/number-input";
import { useId } from "react";

export default function NumberInputDisabled() {
  const id = useId();
  return (
    <div className="grid w-full max-w-xs gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Seats
      </label>
      <NumberInput id={id} defaultValue={5} min={1} disabled />
    </div>
  );
}

API reference

A spinbutton with step buttons. className styles the field; every other native input prop, id and ref included, goes to the input. Typed text is read when you leave the field or press Enter.

PropTypeDefaultDescription
value?number | null–The number, when you control it. null is an empty field.
defaultValue?number | nullnullThe number at first.
onValueChange?(value: number | null) => void–Called with the new number after a step, after typing a number in range, or when you leave the field.
min?number–The lowest number. Home goes here.
max?number–The highest number. End goes here.
step?number1How much the arrow keys and the buttons change it.
largeStep?numberstep × 10How much Page Up and Page Down change it.
format?Intl.NumberFormatOptions–How the number is shown, for example { style: "currency", currency: "USD" }. Typed text is read back the same way.
locale?string–The locale for formatting and reading numbers. Set it when you render on the server, so the server and the browser agree.
size?"sm" | "md" | "lg""md"32, 36 or 40px tall, matching Input.
name?string–Submits the plain number with a form, through a hidden input.
incrementLabel?string"Increase"The plus button's accessible name.
decrementLabel?string"Decrease"The minus button's accessible name.
aria-invalid?boolean–Marks the number as wrong: the border and focus ring turn destructive.
disabled?booleanfalsePrevents changes.
readOnly?booleanfalseShows the number without letting it change.

Related components

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.

  • 60
    Slider

    An accessible React slider for Next.js and Tailwind: one value or a range, with a thumb that grows into a pill showing its value as you reach for it.

  • Button

    An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.