affectUI › Components › Inputs

Number Input

A field for one number – − and + that repeat while held, arrow keys and Page Up / Down, min, max and step, a unit like tokens or %, formatted for the locale.

Free and open source (MIT). Install: npx shadcn@latest add https://www.affectui.com/r/number-input.json · Import from @/components/ui/number-input

When to use

  • Counts and limits people tune in small steps: tokens per run, parallel agents, retries, seats, a budget, a temperature
  • Settings forms where the value has bounds – it never lets a number past min or max get saved

When not to use

  • Numbers nobody counts up or down – phone numbers, card numbers, IDs, ZIP codes: use the Text Field
  • Picking roughly from a wide range where the exact value matters less – a slider says that better
  • A handful of fixed choices (1, 2, 4, 8) – use the Segmented Control or the Select

Usage

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

export function RunLimits() {
  const [tokens, setTokens] = useState<number | null>(32000);

  return (
    <form className="grid gap-4">
      <NumberInput
        label="Max tokens per run"
        unit="tokens"
        value={tokens}
        onValueChange={setTokens}
        min={1000}
        max={200000}
        step={1000}
        hint="The answer stops here."
      />
      <NumberInput
        label="Budget per run"
        name="budget"
        prefix="$"
        defaultValue={25}
        min={0}
        max={100}
        step={5}
        formatOptions={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
      />
    </form>
  );
}

Props

NameTypeDefaultDescription
valuenumber | nullThe number; null is an empty field. Use it with onValueChange to control the field
defaultValuenumber | nullnullThe starting number when the field keeps its own value
onValueChange(value: number | null) => voidCalled after every step, and when typing is done (blur or Enter) – with the clamped number, or null when emptied
minnumberThe lowest value: typing less is raised to it, − and Home stop there
maxnumberThe highest value: typing more is lowered to it, + and End stop there
stepnumber1What − / + and the arrow keys add or take away; steps land on multiples of it (counted from min)
largeStepnumberstep × 10What Page Up / Page Down add or take away
unitReactNodeQuiet text right after the value – "tokens", "%", "ms"; read out with the value
prefixReactNodeQuiet text or a 16px icon before the value – e.g. "$"
localestringthe browser'sShows and reads the number in this locale – 4,000 in en-US, 4.000 in de-DE; typing follows the same separators
formatOptionsIntl.NumberFormatOptionsHow the value is shown once you leave the field, e.g. { maximumFractionDigits: 2 } or { style: "percent" } (then 0.25 shows 25%)
labelReactNodeShown above the field; clicking it focuses the input
hintReactNodeA quiet line under the field – the error takes its place while there is one
errorReactNodeMarks the field invalid (red edge, aria-invalid) and shows the message where the hint was
optionalbooleanfalseAdds "Optional" next to the label
size"sm" | "md" | "lg""md"32 · 36 · 40px high; taller on touch screens
namestringSends the plain number (4000, not "4,000") with the form, through a hidden input
inputClassNamestringExtra class on the input itself
...input propsComponentProps<"input">placeholder, disabled, readOnly, required, onBlur, onKeyDown, aria-label, ref and the rest go to the input

Keyboard

  • ↑ / ↓: One step up or down
  • Page Up / Page Down: A large step (10 steps by default)
  • Home / End: Jumps to min or max, when they're set
  • Enter: Takes what's typed – clamped and formatted – and submits the form as usual
  • Esc: Drops what's typed and goes back to the last value
  • Tab: Moves into the field – one stop per field; − and + are for the pointer, the arrow keys step

Accessibility

  • The input has role="spinbutton" with aria-valuenow, aria-valuemin, aria-valuemax and a readable aria-valuetext ("$25.00", "32,000 tokens")
  • − and + are labelled buttons tied to the input with aria-controls; at a limit they stay focusable and say so with aria-disabled, so focus never drops
  • The hint and the error are linked with aria-describedby; an error sets aria-invalid and is announced politely
  • Holding − or + keeps stepping, first after a pause, then faster; it stops at the limit
  • Phones get a number pad – with a decimal key when the value has decimals, the full keyboard when it can go below zero
  • Focus firms up the field's edge – no ring around the field; the buttons get a clear outline under keyboard focus

Responsive

  • Fills its container; the value hugs its text so the unit sits right after it, and − / + keep their place at the end
  • Very long numbers fit to the field and are cut with an ellipsis before the buttons move
  • Touch screens get taller fields, 32px buttons with a larger hit area and 16px text, so iOS doesn't zoom in
  • Dark mode built in: switches when an ancestor (usually <html>) has the class dark or data-theme="dark"

Requirements

  • React 19

Related

  • Text Field – One input for every form – label, hint and error in place, prefix and suffix, clear, show-password and copy buttons, a character count
  • Form Field – The frame around any control – label with a required mark or Optional, a hint, an error in its place and a count, all wired up with ids and aria
  • Segmented Control – Switch between two to six options – a raised pill or underline that glides, stretches a little and can be dragged across, with icons, counts and badges
  • Select – Pick one option in the Text Field's look – icons and a second line per option, a gliding highlight, type-ahead and a list that never gets cut off