NumberInput
Input with some useful functions for entering numerical values.
Source Style- React
- Svelte
- Vue
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> ) }
/** The demo theme for `NumberInput`, as a CSS Module: one class per part, applied by* the caller. Copy this file into your project and edit it.*/.root {--border-radius: 4px;--stepper-width: 20px;--stepper-icon-size: 12px;--active-color: oklch(59.5% 0.17548 266.373);--border-color: oklch(81% 0 0);/* Room for the padding, the stepper, and a few digits. A flex row that runsshort shrinks its items, and the field inside is sized from the box itsits in — without a floor the input keeps shrinking until nothing but thepadding is left and there is nowhere to type. */--min-width: 90px;display: inline-flex;flex-direction: row;position: relative;width: 140px;min-width: var(--min-width);height: min-content;border: 1px solid var(--border-color);border-radius: var(--border-radius);outline: none;transition: all 0.1s;&:hover {--border-color: oklch(65.335% 0 0);}&:focus-within {--border-color: var(--active-color);box-shadow: 0px 0px 0px 2pxcolor-mix(in srgb, var(--active-color) 10%, transparent);}&:not(:focus-within):has(.field[data-out-of-range]) {border-color: oklch(64.245% 0.21266 25.499);}/* Only leave room for the stepper when one was composed in. */&:not(:has(.stepper)) {--stepper-width: 0px;}}.field {display: block;color: inherit;width: calc(100% - 20px - var(--stepper-width));background-color: inherit;font: inherit;padding: 6px 10px;margin: 0;border: none;outline: none;appearance: none;&[data-readonly] {cursor: not-allowed;}}.stepper {display: flex;flex-direction: column;height: 100%;position: absolute;top: 0;right: 0;width: var(--stepper-width);transition: all 0.1s linear;/* Kept out of the way until the input is being used. */opacity: 0;transform: translateX(40%);}:is(.root:hover, .root:focus-within) .stepper {opacity: 1;transform: translateX(0);}:is(.incrementStepper, .decrementStepper) {border-inline-start: 1px solid oklch(84.522% 0 0);font-size: 0.75rem;display: flex;justify-content: center;align-items: center;flex: 1 1 0%;max-height: 50%;user-select: none;cursor: pointer;&:not([data-disabled]):active {background-color: oklch(37.798% 0.03812 254.612 / 0.055);}&[data-disabled],&[data-readonly] {cursor: not-allowed;}}.incrementStepper {border-top-right-radius: var(--border-radius);}.decrementStepper {border-top: 1px solid oklch(84.522% 0 0);border-bottom-right-radius: var(--border-radius);}/** Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`* are what this site and Storybook set.*/:global(.dark) .root,:global([data-theme='dark']) .root {--border-color: oklch(52% 0 0);}
<script lang="ts">
import { NumberInput } from '@tremolo-ui/svelte'
// Copy this file from the Styling page into your own project.
import numberInputTheme from './NumberInput.module.css'
let value = $state(64)
</script>
<NumberInput.Root class={numberInputTheme.root} bind:value min={0} max={100}>
<NumberInput.InputField class={numberInputTheme.field} aria-label="Value" />
<NumberInput.Stepper class={numberInputTheme.stepper}>
<NumberInput.IncrementStepper class={numberInputTheme.incrementStepper} />
<NumberInput.DecrementStepper class={numberInputTheme.decrementStepper} />
</NumberInput.Stepper>
</NumberInput.Root>
<script setup lang="ts">
import { ref } from 'vue'
import {
NumberInput,
NumberInputDecrementStepper,
NumberInputField,
NumberInputIncrementStepper,
NumberInputStepper,
} from '@tremolo-ui/vue'
// Copy this file from the Styling page into your own project.
import numberInputTheme from './NumberInput.module.css'
const value = ref(64)
</script>
<template>
<NumberInput
v-model="value"
:class="numberInputTheme.root"
:min="0"
:max="100"
>
<NumberInputField :class="numberInputTheme.field" aria-label="Value" />
<NumberInputStepper :class="numberInputTheme.stepper">
<NumberInputIncrementStepper :class="numberInputTheme.incrementStepper" />
<NumberInputDecrementStepper :class="numberInputTheme.decrementStepper" />
</NumberInputStepper>
</NumberInput>
</template>
Import
- React
- Svelte
- Vue
import { NumberInput } from '@tremolo-ui/react'
import { NumberInput } from '@tremolo-ui/svelte'
import {
NumberInput,
NumberInputDecrementStepper,
NumberInputField,
NumberInputIncrementStepper,
NumberInputStepper,
} from '@tremolo-ui/vue'
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.
| Prop | Type | Default |
|---|---|---|
value* | number | — |
min | number | — |
max | number | — |
step | number | 1 |
scale | Scale | linearScale |
format | (value: number) => string | — |
parse | (text: string) => number | — |
clampValue | boolean | true |
wheel | ModifierValue<InputEventOption> | null | ['raw', 1] |
keyboard | ModifierValue<InputEventOption> | null | { default: ['raw', 1], shift: ['raw', 0.1] } |
drag | number | null | 1 |
dragSensitivity | ModifierValue<number> | { default: 1, shift: 0.1 } |
pointerLock | boolean | false |
dragCursor | Cursor | 'ns-resize' |
selectOnFocus | "number" | "none" | "all" | 'none' |
unformatOnFocus | boolean | false |
keepCaretOnStep | boolean | false |
blurOnEnter | boolean | true |
disabled | boolean | — |
readOnly | boolean | — |
onChange | (value: number) => void | — |
onChangeStart | (value: number, source: ChangeSource) => void | — |
onChangeEnd | (value: number, source: ChangeSource) => void | — |
changeEndDelay | number | 500 |
children* | ReactNode | — |
actionsRef | Ref<NumberInputMethods> | — |
Full reference: NumberInputProps
| Data attribute | Values |
|---|---|
[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 attribute | Values |
|---|---|
[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 property | Read by | What it sets |
|---|---|---|
--stepper-icon-size | the component | Size of the default arrow. Set it in your own CSS; a children of your own is sized by you. |
| Data attribute | Values |
|---|---|
[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.
// 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> ) }
/** The demo theme for `NumberInput`, as a CSS Module: one class per part, applied by* the caller. Copy this file into your project and edit it.*/.root {--border-radius: 4px;--stepper-width: 20px;--stepper-icon-size: 12px;--active-color: oklch(59.5% 0.17548 266.373);--border-color: oklch(81% 0 0);/* Room for the padding, the stepper, and a few digits. A flex row that runsshort shrinks its items, and the field inside is sized from the box itsits in — without a floor the input keeps shrinking until nothing but thepadding is left and there is nowhere to type. */--min-width: 90px;display: inline-flex;flex-direction: row;position: relative;width: 140px;min-width: var(--min-width);height: min-content;border: 1px solid var(--border-color);border-radius: var(--border-radius);outline: none;transition: all 0.1s;&:hover {--border-color: oklch(65.335% 0 0);}&:focus-within {--border-color: var(--active-color);box-shadow: 0px 0px 0px 2pxcolor-mix(in srgb, var(--active-color) 10%, transparent);}&:not(:focus-within):has(.field[data-out-of-range]) {border-color: oklch(64.245% 0.21266 25.499);}/* Only leave room for the stepper when one was composed in. */&:not(:has(.stepper)) {--stepper-width: 0px;}}.field {display: block;color: inherit;width: calc(100% - 20px - var(--stepper-width));background-color: inherit;font: inherit;padding: 6px 10px;margin: 0;border: none;outline: none;appearance: none;&[data-readonly] {cursor: not-allowed;}}.stepper {display: flex;flex-direction: column;height: 100%;position: absolute;top: 0;right: 0;width: var(--stepper-width);transition: all 0.1s linear;/* Kept out of the way until the input is being used. */opacity: 0;transform: translateX(40%);}:is(.root:hover, .root:focus-within) .stepper {opacity: 1;transform: translateX(0);}:is(.incrementStepper, .decrementStepper) {border-inline-start: 1px solid oklch(84.522% 0 0);font-size: 0.75rem;display: flex;justify-content: center;align-items: center;flex: 1 1 0%;max-height: 50%;user-select: none;cursor: pointer;&:not([data-disabled]):active {background-color: oklch(37.798% 0.03812 254.612 / 0.055);}&[data-disabled],&[data-readonly] {cursor: not-allowed;}}.incrementStepper {border-top-right-radius: var(--border-radius);}.decrementStepper {border-top: 1px solid oklch(84.522% 0 0);border-bottom-right-radius: var(--border-radius);}/** Dark mode follows whatever marks the page — `.dark` and `[data-theme=dark]`* are what this site and Storybook set.*/:global(.dark) .root,:global([data-theme='dark']) .root {--border-color: oklch(52% 0 0);}
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.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.