Skip to main content

NumberInput

Input with some useful functions for entering numerical values.

Source Style
Loading...
function App() {
  const [value, setValue] = useState(64)

  return (
    <div
      style={{
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <NumberInput.Root
        className={numberInputTheme.root}
        value={value}
        min={0}
        max={100}
        onChange={(v) => setValue(v)}
      >
        <NumberInput.InputField
          className={numberInputTheme.field}
          aria-label="Value"
        />
        <NumberInput.Stepper className={numberInputTheme.stepper}>
          <NumberInput.IncrementStepper
            className={numberInputTheme.incrementStepper}
          />
          <NumberInput.DecrementStepper
            className={numberInputTheme.decrementStepper}
          />
        </NumberInput.Stepper>
      </NumberInput.Root>
    </div>
  )
}

Import​

import { NumberInput } from '@tremolo-ui/react'

API Reference​

Each part lists the props of its own. It also takes the attributes of the element it renders, such as className and style.

In Svelte and Vue the props are the same, written their way — class rather than className, bind:value or v-model, snippets or slots for content. See Svelte and Vue.

State is on the element as a data attribute, so it is a selector away — see Styling. An attribute is only there while its state is on.

Root​

Holds the value, and how it is formatted, parsed and stepped. How every part behaves is set here.

PropTypeDefault
value*number—
minnumber—
maxnumber—
stepnumber1
scaleScalelinearScale
format(value: number) => string—
parse(text: string) => number—
clampValuebooleantrue
wheelModifierValue<InputEventOption> | null['raw', 1]
keyboardModifierValue<InputEventOption> | null{ default: ['raw', 1], shift: ['raw', 0.1] }
dragnumber | null1
dragSensitivityModifierValue<number>{ default: 1, shift: 0.1 }
pointerLockbooleanfalse
dragCursorCursor'ns-resize'
selectOnFocus"number" | "none" | "all"'none'
unformatOnFocusbooleanfalse
keepCaretOnStepbooleanfalse
blurOnEnterbooleantrue
disabledboolean—
readOnlyboolean—
onChange(value: number) => void—
onChangeStart(value: number, source: ChangeSource) => void—
onChangeEnd(value: number, source: ChangeSource) => void—
changeEndDelaynumber500
children*ReactNode—
actionsRefRef<NumberInputMethods>—

Full reference: NumberInputProps

Data attributeValues
[data-disabled]Present when disabled is set
[data-readonly]Present when readonly is set

InputField​

The text field, and the only place the value can be typed. It is the control itself: it takes the focus and carries the ARIA.

It has no props of its own and takes the attributes of an <input>. How it behaves on focus, Enter and the arrow keys is set on NumberInput.Root (selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter).

Data attributeValues
[data-disabled]Present when disabled is set
[data-readonly]Present when readonly is set
[data-out-of-range]Present while the value is outside min / max

Stepper​

The area the steppers sit in, and a drag handle in its own right: dragging it up and down moves the value one step every drag pixels. NumberInput.IncrementStepper and NumberInput.DecrementStepper go inside it.

It has no props of its own and takes the attributes of a <div>.

IncrementStepper / DecrementStepper​

Raise or lower the value by one step, repeating while held.

They have no props of their own and take the attributes of a <div>. Children replace the default arrow.

Custom propertyRead byWhat it sets
--stepper-icon-sizethe componentSize of the default arrow. Set it in your own CSS; a children of your own is sized by you.
Data attributeValues
[data-disabled]Present when disabled is set, or when the value is already at max (increment) / min (decrement)
[data-readonly]Present when readonly is set

Examples​

Units​

The input shows format(value) and reads it back with parse. They have to agree, so unitFormat builds both at once and spreads in as a pair.

Loading...
// Hertz takes SI prefixes, so 1234 is shown as 1.23kHz.
const hz = unitFormat('Hz', { digits: 2 })

// Decibels do not: `d` is already deci, and -6dB is not -0.6B.
const dB = unitFormat('dB', { prefixes: false, digits: 1 })

// This one is stored in milliseconds, so 1500 is shown as 1.5s.
const ms = unitFormat('s', { base: 'm', digits: 2 })

function App() {
  const [frequency, setFrequency] = useState(1234)
  const [gain, setGain] = useState(-6.25)
  const [release, setRelease] = useState(1500)

  return (
    <div style={{ display: 'flex', gap: '1rem', justifyContent: 'center' }}>
      <NumberInput.Root
        className={numberInputTheme.root}
        {...hz}
        value={frequency}
        min={20}
        max={22000}
        onChange={setFrequency}
      >
        <NumberInput.InputField
          className={numberInputTheme.field}
          aria-label="Frequency"
        />
      </NumberInput.Root>
      <NumberInput.Root
        className={numberInputTheme.root}
        {...dB}
        value={gain}
        min={-60}
        max={6}
        step={0.1}
        onChange={setGain}
      >
        <NumberInput.InputField
          className={numberInputTheme.field}
          aria-label="Gain"
        />
      </NumberInput.Root>
      <NumberInput.Root
        className={numberInputTheme.root}
        {...ms}
        value={release}
        min={1}
        max={10000}
        onChange={setRelease}
      >
        <NumberInput.InputField
          className={numberInputTheme.field}
          aria-label="Release time"
        />
      </NumberInput.Root>
    </div>
  )
}

unitFormat picks an SI prefix from the magnitude of the value: 1234 reads as 1.23kHz, 0.0005 as 500µHz, and 0 stays in the base unit. It reads µ, μ and u back as micro, since only the last of those can be typed.

Two options cover the cases where that is not what you want.

  • prefixes: false leaves the number alone and appends the symbol as written. Use it for anything that is not an SI quantity — dB, %, cents, semitones. Prefixing dB is not merely unusual but wrong: d is deci, so -6dB would read as -0.6B.
  • base says which prefix the stored value is already in. A control that keeps milliseconds is unitFormat('s', { base: 'm' }): 1500 displays as 1.5s, and parse gives 1500 back.

What the display can show​

A rounded display and a fine step are each fine on their own, and neither is checked against the other. format may round as much as it likes — the value keeps its own precision, and unformatOnFocus puts the plain number in the field when you click into it.

What is worth avoiding is a display so coarse that a key press cannot be seen at all: two decimals of a value in seconds cannot show a step of one millisecond, and the input sits there looking broken. A development build says so when it happens:

[tremolo-ui] NumberInput: `keyboard` moves the value, but `format` shows the
same text before and after, everywhere in the range. The display is too coarse
for it to be seen.

The same warning covers a keyboard or wheel amount finer than step, which rounds every press straight back to where it started. Production builds carry neither the check nor the message.

format and parse are ordinary props, so a unit that does not fit this shape is written by hand. parse should return NaN for text it cannot read — the input then keeps the value it had, rather than committing a number nobody typed.

Editing a formatted value​

format runs on every render, so a field showing 1.23kHz still says 1.23kHz once you click into it. Editing that means either replacing the whole thing or putting the caret inside a unit string.

unformatOnFocus on NumberInput.Root drops the format while the field has focus and shows the plain value instead.

<NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={1230} unformatOnFocus …>
<NumberInput.InputField />
</NumberInput.Root>
not focused 1.23kHz
focused 1230

The number shown is the value, not the number inside the formatted text. Those differ whenever the format scales: offering 1.23 for editing would read back as 1.23 and lose a factor of a thousand.

It is also why a rounded display stops leaking into the value. A field with digits: 0 shows 2Hz for a value of 1.6; focusing it offers 1.6, so editing and committing no longer rounds the value to 2.

selectOnFocus still applies, and the selection is measured against the text that ends up on screen. With the format dropped, 'number' and 'all' come to the same thing, since the whole text is the number.

Both props default to off, so nothing changes until you ask for it.

Stepping a digit​

An arrow key replaces the whole value, and a controlled <input> whose value is replaced drops the caret at the end. Held down, the second press onwards therefore always acts on the last digit.

keepCaretOnStep puts it back.

<NumberInput.Root value={value} keepCaretOnStep …>
<NumberInput.InputField />
</NumberInput.Root>

The position is measured from the decimal point, not from either end, so it survives the number changing length. The caret between 9 and .9 is still between 10 and .0 — measured from the front it would have slid onto the wrong column as the extra digit appeared.

It restores the caret and nothing else. Which digit it sits on does not change the size of the step; that is keyboard's to say. Off by default.