NumberInput
Input with some useful functions for entering numerical values.
Source StyleImport
import { NumberInput } from '@tremolo-ui/react'
Examples
Basic
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.
// 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: falseleaves 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:dis deci, so-6dBwould read as -0.6B.basesays which prefix the stored value is already in. A control that keeps milliseconds isunitFormat('s', { base: 'm' }): 1500 displays as1.5s, andparsegives 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.