メインコンテンツまでスキップ

NumberInput

Input with some useful functions for entering numerical values.

Source Style

Import

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

Examples

Basic

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

  return (
    <div
      style={{
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <NumberInput.Root
        value={value}
        min={0}
        max={100}
        onChange={(v) => setValue(v)}
      >
        <NumberInput.InputField />
        <NumberInput.Stepper>
          <NumberInput.IncrementStepper />
          <NumberInput.DecrementStepper />
        </NumberInput.Stepper>
      </NumberInput.Root>
    </div>
  )
}

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
        {...hz}
        value={frequency}
        min={20}
        max={22000}
        onChange={setFrequency}
      >
        <NumberInput.InputField />
      </NumberInput.Root>
      <NumberInput.Root
        {...dB}
        value={gain}
        min={-60}
        max={6}
        step={0.1}
        onChange={setGain}
      >
        <NumberInput.InputField />
      </NumberInput.Root>
      <NumberInput.Root
        {...ms}
        value={release}
        min={1}
        max={10000}
        onChange={setRelease}
      >
        <NumberInput.InputField />
      </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.InputField drops the format while the field has focus and shows the plain value instead.

<NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={1230}>
<NumberInput.InputField unformatOnFocus />
</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.InputField keepCaretOnStep />

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.