Number Field
A text input that knows it holds a number. It parses and formats through
Intl.NumberFormat, steps with the arrow keys and two stepper buttons, clamps
to min and max, and hands a form a real number | null rather than a string
that happens to look numeric.
Between 1 and 99.
The part worth knowing about is the scrub area. Wrap it around the label and the label becomes a drag handle — drag sideways over the word "Quantity" and the number follows. It is the interaction Figma, Blender and every DAW taught people, and it is faster than any stepper for roughing a value in.
Import
import { NumberField } from "@forte-ui/react";Anatomy
<NumberField.Root>
<NumberField.ScrubArea>
{/* the label — this is what you drag */}
</NumberField.ScrubArea>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>Group is the element that draws the box; the input inside it is borderless and
transparent, so the three segments read as one control. The steppers ship a plus
and a minus glyph, and Base UI labels them "Increase" and "Decrease" — pass
children to either one to replace the glyph without losing the label.
Scrubbing
Scrubbing is invisible. Nothing about a label suggests it can be dragged, and a
control whose best interaction nobody finds may as well not have it — so
ScrubArea ships three cues by default, and they cover three different moments.
At rest — the grip. A double-headed arrow after the label, at
--forte-number-field-scrub-grip-opacity (0.7), so it reads as an affordance
without competing with the words next to it. This is the only cue present before
the user has done anything, and the only one a touch user ever sees, which is
why it is on by default. Turn it off with grip={false} when the surrounding
design carries the affordance some other way — not to tidy the layout.
On hover — the cursor and a wash. ew-resize, or ns-resize when
direction="vertical", plus a panel-coloured background behind the label and
the grip at full opacity.
During the drag — a tint and a stand-in pointer. data-scrubbing lands on
the root and every part under it: the scrub area takes the primary tint, and the
Group's border turns primary as well, which is what connects a drag
happening up on the label to the number it is changing. Scrubbing also takes a
pointer lock,
and a locked pointer is an invisible one — so ScrubArea renders a
ScrubAreaCursor to stand in for it. Pass cursor={false} and render your own
if you want a different glyph.
Sensitivity and direction
pixelSensitivity is how far the pointer travels for one step, which makes it a
precision control: raise it when the range is small and every value counts, drop
it when the user is roughing a number in.
1px per step
2px per step
12px per step
direction="vertical" swaps the axis — the cursor becomes ns-resize and the
grip rotates 90° to match. teleportDistance bounds how far the stand-in cursor
may travel before it wraps to the opposite edge, which keeps a long drag from
running off the screen.
The wheel
The second scrub gesture, and the quiet one. allowWheelScrub on Root lets
the wheel move the value, but only while the input is both focused and
hovered — so an ordinary page scroll can never change a number in passing.
Click into the field, then scroll over it.
Examples
Sizes
size moves height, inline padding and glyph size together, from the same
--forte-control-* tokens Input and Select.Trigger read — so all three line up
on one row at every data-forte-density setting. The steppers stay square because
they take their height from the group rather than declaring one.
Orientation
orientation="horizontal" on Root puts the scrub area on the same row as the
group, label first, and centres it on the control's height. It is the shape of
an inspector panel or a toolbar, where stacking the label would double the
height of every row. Only the layout moves: the scrub area's direction is
still the axis the pointer travels along, and the two are independent.
The group keeps its natural width unless it is fullWidth, in which case it
takes whatever the row has left after the label — the label never wraps to
give it more.
Variants
The same three Input has: outline reads as a form control, soft as a
filled field, ghost as an inline affordance. ghost also drops the rules
between the segments, which are what keep the buttons legible in the other two.
Formatting
format takes Intl.NumberFormatOptions verbatim, so currency, percent and unit
displays are a prop rather than a wrapper — and the field parses back what it
printed, so a user can type $1,250 into the first field below.
Steps
step is the ordinary increment, smallStep the Alt one and
largeStep the Shift one, and all three apply to the arrow keys, the
stepper buttons and the scrub alike.
snapOnStep pulls the result of a step onto multiples of the step, so a
field sitting off-grid at 7 with step={2} moves to 8 rather than 9. It does
not touch a value the user typed — type 7 into the second field below and it
stays 7 until you step it, which is what keeps the control from rewriting an
answer nobody asked it to round.
Alt = 0.1 · Shift = 5
Type 7, then press +.
States
Disabled drops to 55% opacity and takes cursor: not-allowed. Read-only keeps
full contrast — the value is still there to be read and copied — and only stops
offering to change it. Both hide the grip: an affordance for a gesture that
cannot happen is worse than none. Invalid recolours the group's boundary and
nothing else, so the user's own answer never looks like the error message.
In a form
The name goes on the Field.Root and the hidden input carries it, so what
arrives in the submitted values is a real number — or null for an empty field,
which is what makes "cleared" distinguishable from zero.
Keyboard
| Key | Behaviour |
|---|---|
| ↑ | Increase by step. |
| ↓ | Decrease by step. |
| Shift then ↑ / ↓ | Step by largeStep (10 by default). |
| Alt then ↑ / ↓ | Step by smallStep (0.1 by default). |
| Home | Jump to min — only when a min is set; otherwise the caret moves, as in any text field. |
| End | Jump to max — only when a max is set. |
| Tab | One stop: the text field. The steppers are deliberately not in the tab order. |
The whole control is a single tab stop. Base UI gives both steppers
tabIndex={-1}, which is the right answer for a spin button: the arrow keys
already do what the buttons do, from the field the user is already in, so
putting two more stops between this control and the next one would cost keyboard
users two presses per field and buy them nothing. The buttons keep their
aria-label and their aria-controls, so they are still described.
Accessibility
The input needs an accessible name, and <Field.Label> is the way to give it
one — it renders a real <label> wired with htmlFor, which is also what makes
the scrub area's label clickable-to-focus as well as draggable. Base UI adds
aria-roledescription="Number field": the label names the control, the role
description explains it.
Focus is drawn once, never twice. The group carries the ring, so focusing the
text field rings the whole control the way focusing an Input rings the whole
input. The steppers carry an inset ring of their own and the group's ring is
suppressed while one of them holds focus — which nothing reaches by default,
since they are not tab stops, and which exists for the app that puts them back
in the order with tabIndex={0}. Without the suppression that app would get two
rings at once, because :has(:focus-visible) on the group is true for a focused
stepper as well.
The steppers also carry .forte-target, because at sm under
data-forte-density="compact" the button inside the group's border is 22px —
under the 24×24 floor of SC 2.5.8 — and the expander fixes the hit area without
repainting anything.
The digits are set in tabular-nums. That is not typographic polish: a scrub
rewrites the value every couple of pixels of pointer travel, and proportional
figures change width as the digits change, so the number visibly breathes for
the whole drag.
In forced-colors mode the group paints Field / FieldText with a FieldText
boundary — it is a <div>, so the UA assigns it nothing on its own, and the
soft and ghost variants' transparent border would otherwise be replaced
with a colour of the engine's choosing rather than left invisible. The grip's
resting opacity is reset to 1 there for the same reason a disabled control's
is: opacity is one of the few properties forced-colors does not override,
so a 0.7 grip would stay 30% transparent against system colours and read as
disabled chrome.
Theming
Every property below is declared on the part that paints it — the group, the
input, a stepper, the scrub area — and an element's own declaration beats an
inherited one, so setting these on an ancestor does nothing. Set them on the
part, through className (an unlayered rule beats @layer forte.components
whatever its specificity) or an inline style.
The global tokens they resolve to — --forte-color-*, --forte-control-*,
--forte-radius-* — are the exception: this component only reads those, so
re-pointing them from :root or a theme scope moves every number field at once.
| Property | Controls | Default |
|---|---|---|
--forte-number-field-gap | Space between the scrub area and the group, on either axis | var(--forte-space-2) |
--forte-number-field-height | Control height (reset per size) | var(--forte-control-h-md) |
--forte-number-field-radius | Corner radius | var(--forte-radius-control) |
--forte-number-field-border-width | Border width | 1px |
--forte-number-field-separator-width | Rule between a stepper and the input | 1px |
--forte-number-field-separator-color | Colour of the rule between a stepper and the input (reset per variant) | var(--forte-color-border-muted) |
--forte-number-field-bg | Background (reset per variant) | var(--forte-color-background) |
--forte-number-field-border-color | Border colour (reset per variant, by data-invalid, and while scrubbing) | var(--forte-color-border) |
--forte-number-field-border-color-hover | Border colour on hover | var(--forte-color-border-strong) |
--forte-number-field-input-padding-x | Inline padding (reset per size) | var(--forte-control-px-md) |
--forte-number-field-input-font-size | Font size (reset per size) | var(--forte-font-size-2) |
--forte-number-field-input-width | Room for the DIGITS at rest, before any fullWidth stretching. The inline padding is added to it below rather than eaten out of it, so the knob means what it says: 5ch is five characters of room, not a box five characters wide with the padding taken out of the middle | 5ch |
--forte-number-field-input-text-align | Where the digits sit. center because the field is flanked by two steppers; end reads better in a table of figures | center |
--forte-number-field-input-fg | Text colour | var(--forte-color-foreground) |
--forte-number-field-input-placeholder-fg | Placeholder colour | var(--forte-color-foreground-muted) |
--forte-number-field-input-caret-color | Caret colour | var(--forte-color-primary) |
--forte-number-field-input-selection-bg | Selection background | var(--forte-color-primary-soft) |
--forte-number-field-input-selection-fg | Selection text colour | var(--forte-color-primary-text) |
--forte-number-field-stepper-icon-size | Stepper glyph size (reset per size) | var(--forte-font-size-3) |
--forte-number-field-stepper-fg | Stepper glyph colour | var(--forte-color-foreground-muted) |
--forte-number-field-stepper-fg-hover | Stepper glyph colour on hover | var(--forte-color-foreground) |
--forte-number-field-stepper-bg-hover | Stepper background on hover | var(--forte-color-panel-hover) |
--forte-number-field-stepper-bg-active | Stepper background while held | var(--forte-color-panel-active) |
--forte-number-field-stepper-press-scale | How far a stepper squashes while held | var(--forte-scale-press) |
--forte-number-field-scrub-gap | Space between the label and the grip | var(--forte-space-1) |
--forte-number-field-scrub-padding-x | Inline padding of the drag surface, and the negative margin that keeps the label optically aligned with the group under it | var(--forte-space-1) |
--forte-number-field-scrub-radius | Corner radius of the drag surface | var(--forte-radius-2) |
--forte-number-field-scrub-bg | Background of the drag surface (reset while scrubbing) | transparent |
--forte-number-field-scrub-bg-hover | Background of the drag surface on hover | var(--forte-color-panel-hover) |
--forte-number-field-scrub-grip-fg | Grip colour (reset while scrubbing) | var(--forte-color-foreground-subtle) |
--forte-number-field-scrub-grip-opacity | How present the grip is at rest. It has to read as an affordance without competing with the label it sits beside | 0.7 |
--forte-number-field-scrub-cursor | The pointer shown over the drag surface. Reset to ns-resize by direction="vertical" | ew-resize |
--forte-number-field-scrub-cursor-z-index | Stacking order of the stand-in pointer. One band above Tooltip: it replaces the mouse cursor, so nothing may cover it | 60 |
--forte-number-field-scrub-cursor-fg | Fill of the stand-in pointer | var(--forte-color-foreground) |
--forte-number-field-scrub-cursor-halo | The halo painted behind the fill, so the glyph stays readable over whatever the drag happens to cross | var(--forte-color-background) |
--forte-number-field-scrub-cursor-halo-width | Halo thickness | 3px |
API reference
The tables list what each part adds on top of Base UI. Everything else passes straight through.
NumberField.Root
Also takes Base UI's own root props unchanged: value, defaultValue,
onValueChange, onValueCommitted, min, max, step, smallStep,
largeStep, snapOnStep, allowOutOfRange, allowWheelScrub, format,
locale, name, form, id, required, disabled, readOnly, inputRef
and render.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
orientation | NumberFieldOrientation | vertical | Where the `ScrubArea` sits relative to the `Group`. `vertical` stacks the label above the control, the way a `Field` does; `horizontal` puts it on the same row, before the control — the shape of an inspector panel or a toolbar, where a stacked label would double the height of every row. Only the layout changes. The scrub gesture keeps its own `direction`, which is the axis the pointer travels along, not where the label lives. |
size | NumberFieldSize | md | Size of the control. Height, inline padding, stepper width and font size all move together, and the numbers come from the same `--forte-control-*` tokens `Input` and `Select.Trigger` read — so the three line up on one row at every `data-forte-density` setting. |
variant | NumberFieldVariant | outline | How much visual weight the control carries. `outline` reads as a form control, `soft` as a filled field, `ghost` as an inline affordance — the same three `Input` has. |
NumberField.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
fullWidth | boolean | false | Stretch the group to fill the width of its container. Only needed outside a `Field.Root` — a field is a flex column, so a group inside one already stretches. |
NumberField.Input
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NumberField.Increment
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | <PlusIcon /> | Glyph to render. Defaults to a plus sign. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NumberField.Decrement
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | <MinusIcon /> | Glyph to render. Defaults to a minus sign. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NumberField.ScrubArea
Also takes Base UI's teleportDistance.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
cursor | boolean | true | Render a `ScrubAreaCursor` with the default glyph. Scrubbing takes a pointer lock, and a locked pointer is an INVISIBLE one — without a stand-in the user drags with no cursor at all. That is why this defaults to on. Set it to `false` when you want to supply your own, and render `<NumberField.ScrubAreaCursor>` inside the scrub area yourself. |
direction | NumberFieldScrubDirection | horizontal | Axis the pointer has to travel along. Also picks the resize cursor and the rotation of both scrub glyphs. |
grip | boolean | true | Render the double-headed arrow after the children. This is the whole reason a scrub area is discoverable: nothing else about a label suggests it can be dragged, and a cursor change only arrives once the pointer is already on it. Turn it off when the surrounding design carries the affordance some other way — never just to tidy the layout. |
NumberField.ScrubAreaCursor
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | <ScrubCursorIcon /> | Glyph to render. Defaults to a haloed double-headed arrow, rotated to match the scrub area's `direction`. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |