Skip to content

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.

number-field/basic.tsx

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.

number-field/scrubbing.tsx

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.

number-field/sensitivity.tsx

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.

number-field/wheel.tsx

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.

number-field/sizes.tsx

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.

number-field/orientation.tsx

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.

number-field/variants.tsx

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.

number-field/format.tsx

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.

number-field/steps.tsx

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.

number-field/states.tsx
Must be even.

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.

number-field/form.tsx

Type 9 and submit.

Keyboard

Keyboard interactions
KeyBehaviour
Increase by step.
Decrease by step.
Shift then ↑ / ↓Step by largeStep (10 by default).
Alt then ↑ / ↓Step by smallStep (0.1 by default).
HomeJump to min — only when a min is set; otherwise the caret moves, as in any text field.
EndJump to max — only when a max is set.
TabOne 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.

Theming tokens for NumberField
PropertyControlsDefault
--forte-number-field-gapSpace between the scrub area and the group, on either axisvar(--forte-space-2)
--forte-number-field-heightControl height (reset per size)var(--forte-control-h-md)
--forte-number-field-radiusCorner radiusvar(--forte-radius-control)
--forte-number-field-border-widthBorder width1px
--forte-number-field-separator-widthRule between a stepper and the input1px
--forte-number-field-separator-colorColour of the rule between a stepper and the input (reset per variant)var(--forte-color-border-muted)
--forte-number-field-bgBackground (reset per variant)var(--forte-color-background)
--forte-number-field-border-colorBorder colour (reset per variant, by data-invalid, and while scrubbing)var(--forte-color-border)
--forte-number-field-border-color-hoverBorder colour on hovervar(--forte-color-border-strong)
--forte-number-field-input-padding-xInline padding (reset per size)var(--forte-control-px-md)
--forte-number-field-input-font-sizeFont size (reset per size)var(--forte-font-size-2)
--forte-number-field-input-widthRoom 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 middle5ch
--forte-number-field-input-text-alignWhere the digits sit. center because the field is flanked by two steppers; end reads better in a table of figurescenter
--forte-number-field-input-fgText colourvar(--forte-color-foreground)
--forte-number-field-input-placeholder-fgPlaceholder colourvar(--forte-color-foreground-muted)
--forte-number-field-input-caret-colorCaret colourvar(--forte-color-primary)
--forte-number-field-input-selection-bgSelection backgroundvar(--forte-color-primary-soft)
--forte-number-field-input-selection-fgSelection text colourvar(--forte-color-primary-text)
--forte-number-field-stepper-icon-sizeStepper glyph size (reset per size)var(--forte-font-size-3)
--forte-number-field-stepper-fgStepper glyph colourvar(--forte-color-foreground-muted)
--forte-number-field-stepper-fg-hoverStepper glyph colour on hovervar(--forte-color-foreground)
--forte-number-field-stepper-bg-hoverStepper background on hovervar(--forte-color-panel-hover)
--forte-number-field-stepper-bg-activeStepper background while heldvar(--forte-color-panel-active)
--forte-number-field-stepper-press-scaleHow far a stepper squashes while heldvar(--forte-scale-press)
--forte-number-field-scrub-gapSpace between the label and the gripvar(--forte-space-1)
--forte-number-field-scrub-padding-xInline padding of the drag surface, and the negative margin that keeps the label optically aligned with the group under itvar(--forte-space-1)
--forte-number-field-scrub-radiusCorner radius of the drag surfacevar(--forte-radius-2)
--forte-number-field-scrub-bgBackground of the drag surface (reset while scrubbing)transparent
--forte-number-field-scrub-bg-hoverBackground of the drag surface on hovervar(--forte-color-panel-hover)
--forte-number-field-scrub-grip-fgGrip colour (reset while scrubbing)var(--forte-color-foreground-subtle)
--forte-number-field-scrub-grip-opacityHow present the grip is at rest. It has to read as an affordance without competing with the label it sits beside0.7
--forte-number-field-scrub-cursorThe pointer shown over the drag surface. Reset to ns-resize by direction="vertical"ew-resize
--forte-number-field-scrub-cursor-z-indexStacking order of the stand-in pointer. One band above Tooltip: it replaces the mouse cursor, so nothing may cover it60
--forte-number-field-scrub-cursor-fgFill of the stand-in pointervar(--forte-color-foreground)
--forte-number-field-scrub-cursor-haloThe halo painted behind the fill, so the glyph stays readable over whatever the drag happens to crossvar(--forte-color-background)
--forte-number-field-scrub-cursor-halo-widthHalo thickness3px

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.

Props for NumberFieldRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
orientationNumberFieldOrientationverticalWhere 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.
sizeNumberFieldSizemdSize 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.
variantNumberFieldVariantoutlineHow 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

Props for NumberFieldGroup
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
fullWidthbooleanfalseStretch 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

Props for NumberFieldInput
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

NumberField.Increment

Props for NumberFieldIncrement
PropTypeDefaultDescription
childrenReactNode<PlusIcon />Glyph to render. Defaults to a plus sign.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

NumberField.Decrement

Props for NumberFieldDecrement
PropTypeDefaultDescription
childrenReactNode<MinusIcon />Glyph to render. Defaults to a minus sign.
classNamestringAdditional 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.

Props for NumberFieldScrubArea
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
cursorbooleantrueRender 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.
directionNumberFieldScrubDirectionhorizontalAxis the pointer has to travel along. Also picks the resize cursor and the rotation of both scrub glyphs.
gripbooleantrueRender 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

Props for NumberFieldScrubAreaCursor
PropTypeDefaultDescription
childrenReactNode<ScrubCursorIcon />Glyph to render. Defaults to a haloed double-headed arrow, rotated to match the scrub area's `direction`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.