FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • ToggleGroup
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Toolbar
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • NumberField
  • Field & Form
  • Progress
  • Meter

Resources

  • Roadmap & changelog
  • GitHub ↗

NumberField

A number field: the user types in their locale's notation, steps with the keys or two buttons, and the form posts a machine number. The platform's <input type="number"> cannot be the visible control (it cannot format, it localises by the browser's language rather than the page's, and it is a text field to VoiceOver anyway), so NumberField.Input renders two inputs: a visible type="text" input the user edits, and right after it a visually hidden native type="number" input that carries name, min / max / step / required, the posted value, the form reset and the browser's own constraint messages. The library adds the Async React half: the typed text is urgent, a step is a transition, and each gesture is one commit with data-pending and a rollback if the save fails.

In the cart: 1
Show code
NumberFieldQuantity.tsx
"use client";
import { startTransition, useState } from "react";
import { NumberField } from "@funstack/ui/number-field";
import { fakeSave } from "./fake-api.js";

// A cart line. Type a number (committed on blur or Enter), use the arrow keys, or press and hold
// a button: the save runs once per gesture in valueCommitAction (800 ms, data-pending on the Root,
// the Input and the pressed button). The parent adopts the quantity when the save succeeds; with
// failures on it keeps the old one, so the text returns to it.
export function NumberFieldQuantity() {
  const [fail, setFail] = useState(false);
  const [quantity, setQuantity] = useState<number | null>(1);
  return (
    <div className="demo-stack">
      <label className="demo-field">
        <input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} />
        Make the save fail
      </label>
      <NumberField.Root
        className="demo-number-field"
        locale="en-US"
        value={quantity}
        min={1}
        max={99}
        valueCommitAction={async (next) => {
          try {
            await fakeSave(`quantity ${next}`, { fail });
            startTransition(() => setQuantity(next));
          } catch {
            // The parent keeps its quantity: the field shows it again.
          }
        }}
      >
        {({ pending }) => (
          <>
            <NumberField.Label className="demo-number-label">Quantity</NumberField.Label>
            <div className="demo-number-group">
              <NumberField.Decrement className="demo-number-step">−</NumberField.Decrement>
              <NumberField.Input className="demo-number-input" />
              <NumberField.Increment className="demo-number-step">+</NumberField.Increment>
            </div>
            <span className="demo-number-status">
              {pending ? "Saving…" : `In the cart: ${String(quantity)}`}
            </span>
          </>
        )}
      </NumberField.Root>
    </div>
  );
}
number-field.css
/* NumberField: the Root is the group (a grid of the two buttons around the input). The visible
   input is a plain text field; the hidden number input after it is positioned by the library. */
.demo-number-field {
  display: grid;
  gap: 0.35rem;
  justify-items: start;
}

.demo-number-label {
  font-weight: 600;
  color: var(--color-text);
}

.demo-number-group {
  display: inline-grid;
  grid-template-columns: auto 1fr auto;
  align-items: stretch;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  overflow: hidden;
  background: var(--color-bg);
}

.demo-number-group:has(.demo-number-input:focus-visible) {
  outline: 2px solid var(--color-primary);
  outline-offset: 1px;
}

.demo-number-input {
  inline-size: 9ch;
  min-inline-size: 6ch;
  border: 0;
  padding: 0.4rem 0.6rem;
  background: transparent;
  color: var(--color-text);
  font: inherit;
  font-variant-numeric: tabular-nums;
  text-align: end;
}

.demo-number-input:focus {
  outline: none;
}

.demo-number-input:user-invalid {
  color: #dc2626;
}

.demo-number-input[data-pending] {
  color: var(--color-text-secondary);
}

.demo-number-step {
  inline-size: 2.25rem;
  border: 0;
  background: var(--color-bg-secondary, transparent);
  color: var(--color-text);
  font: inherit;
  font-size: 1.1rem;
  cursor: pointer;
}

.demo-number-step:active {
  background: var(--color-border);
}

.demo-number-step:disabled {
  opacity: 0.4;
  cursor: default;
}

.demo-number-step[data-pending] {
  cursor: progress;
}

.demo-number-field[data-invalid] .demo-number-group,
.demo-number-group:has(:user-invalid) {
  border-color: #dc2626;
}

.demo-number-status {
  color: var(--color-text-secondary);
  font-size: 0.875rem;
}

Two actions: steps and commits

valueChangeActionvalueCommitAction
CalledOn every step: the parsed text at its commit, each key press, each tick of a held button, each wheel event, a form reset, text found at hydration, and the revert after a failed save (reason: "dismiss", uncontrolled).Once per gesture: a typed text's blur or Enter, a held button's release, each key press, each wheel event, a form reset, text found at hydration.
LaneA transition of its own: a controlled parent's setValue(next) in the synchronous prefix renders interruptibly.The Root's commit transition, with useOptimistic.
Pendingdata-pending while a step is in flight. Without a valueChangeAction a step starts no transition.data-pending on the Root, the Input and the pressed button until the save settles.
Uncontrolled stateNot written.Written when it resolves; reverts to the value before the gesture if it rejects.
Meant forAdopting the value in a controlled parent, a live total, cheap synchronous work.Saving: a Server Function, a fetch, requestSubmit().

The save goes in valueCommitAction

A held button steps every 60 ms, and every pending async action entangles every transition in the app. Keep valueChangeAction cheap and put the network call in valueCommitAction, which runs once per gesture. A typed value and a key press fire both, with the same value; only a held button separates them.

  • Typed text is committed on blur and on Enter, never per keystroke. -, 1, and 12 on the way to 123 are not the number the user means. The draft is internal, urgent state: typing never waits for a transition. A step (a key, a button) parses an open draft first.
  • Text that does not parse (abc, 1,5 in English) reverts to the committed value's text with no dispatch. An empty field commits null.
  • The input shows the value the gesture reached while a button is held, a step waits for its commit, or anything is in flight, so it never snaps back between ticks. A controlled value that changes from outside shows at once, unless the user has a draft open: the draft wins until it is committed.
  • A rejected save reaches the nearest Error Boundary above the Root. For a visible rollback, catch inside the action and adopt the value only on success, as the example does: the text and the hidden input return to the parent's value.

Locales and formats

value: 12.5
Show code
NumberFieldFormats.tsx
"use client";
import { useState } from "react";
import { NumberField } from "@funstack/ui/number-field";

const locales = ["en-US", "de-DE", "fr-FR", "ar-EG"] as const;
type Locale = (typeof locales)[number];

// The buttons' built-in names are English; pass your own next to the locale.
const labels: Record<Locale, { increase: string; decrease: string }> = {
  "en-US": { increase: "Increase", decrease: "Decrease" },
  "de-DE": { increase: "Erhöhen", decrease: "Verringern" },
  "fr-FR": { increase: "Augmenter", decrease: "Diminuer" },
  "ar-EG": { increase: "زيادة", decrease: "إنقاص" },
};

// A price in euros and a percentage, formatted and parsed in the chosen locale. The text is the
// locale's (`12,50 €`, `€12.50`, `١٢٫٥٠ €`); the value and the posted form value are plain numbers.
// Try `1,5` in English (not a number: it reverts) and in German (1.5).
export function NumberFieldFormats() {
  const [locale, setLocale] = useState<Locale>("de-DE");
  const [price, setPrice] = useState<number | null>(12.5);
  const { increase, decrease } = labels[locale];
  return (
    <div className="demo-stack">
      <label className="demo-field">
        Locale
        <select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}>
          {locales.map((l) => (
            <option key={l}>{l}</option>
          ))}
        </select>
      </label>
      <NumberField.Root
        className="demo-number-field"
        locale={locale}
        format={{ style: "currency", currency: "EUR" }}
        value={price}
        min={0}
        step={0.5}
        valueChangeAction={(next) => {
          setPrice(next);
        }}
      >
        <NumberField.Label className="demo-number-label">Price</NumberField.Label>
        <div className="demo-number-group">
          <NumberField.Decrement className="demo-number-step" aria-label={decrease}>
            −
          </NumberField.Decrement>
          <NumberField.Input className="demo-number-input" />
          <NumberField.Increment className="demo-number-step" aria-label={increase}>
            +
          </NumberField.Increment>
        </div>
      </NumberField.Root>
      <output className="demo-readout">value: {String(price)}</output>
      <NumberField.Root
        className="demo-number-field"
        locale={locale}
        format={{ style: "percent" }}
        defaultValue={0.25}
        min={0}
        max={1}
        stepOnWheel
      >
        <NumberField.Label className="demo-number-label">Discount (wheel on)</NumberField.Label>
        <div className="demo-number-group">
          <NumberField.Decrement className="demo-number-step" aria-label={decrease}>
            −
          </NumberField.Decrement>
          <NumberField.Input className="demo-number-input" />
          <NumberField.Increment className="demo-number-step" aria-label={increase}>
            +
          </NumberField.Increment>
        </div>
      </NumberField.Root>
    </div>
  );
}
number-field.css
/* NumberField: the Root is the group (a grid of the two buttons around the input). The visible
   input is a plain text field; the hidden number input after it is positioned by the library. */
.demo-number-field {
  display: grid;
  gap: 0.35rem;
  justify-items: start;
}

.demo-number-label {
  font-weight: 600;
  color: var(--color-text);
}

.demo-number-group {
  display: inline-grid;
  grid-template-columns: auto 1fr auto;
  align-items: stretch;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  overflow: hidden;
  background: var(--color-bg);
}

.demo-number-group:has(.demo-number-input:focus-visible) {
  outline: 2px solid var(--color-primary);
  outline-offset: 1px;
}

.demo-number-input {
  inline-size: 9ch;
  min-inline-size: 6ch;
  border: 0;
  padding: 0.4rem 0.6rem;
  background: transparent;
  color: var(--color-text);
  font: inherit;
  font-variant-numeric: tabular-nums;
  text-align: end;
}

.demo-number-input:focus {
  outline: none;
}

.demo-number-input:user-invalid {
  color: #dc2626;
}

.demo-number-input[data-pending] {
  color: var(--color-text-secondary);
}

.demo-number-step {
  inline-size: 2.25rem;
  border: 0;
  background: var(--color-bg-secondary, transparent);
  color: var(--color-text);
  font: inherit;
  font-size: 1.1rem;
  cursor: pointer;
}

.demo-number-step:active {
  background: var(--color-border);
}

.demo-number-step:disabled {
  opacity: 0.4;
  cursor: default;
}

.demo-number-step[data-pending] {
  cursor: progress;
}

.demo-number-field[data-invalid] .demo-number-group,
.demo-number-group:has(:user-invalid) {
  border-color: #dc2626;
}

.demo-number-status {
  color: var(--color-text-secondary);
  font-size: 0.875rem;
}
  • format is Intl.NumberFormatOptions: style "decimal" (default), "percent" (the value is a fraction, 0.25 shows 25%; default step 0.01), "currency" and "unit", plus fraction digits, grouping, sign display and numbering system.
  • Pass locale. The default is the runtime's locale (on the server and in the browser alike); a development warning fires when the page's lang disagrees and no locale was given.
  • The parser accepts what the formatter produces: the locale's decimal and group separators and digits (Arabic-Indic, Devanagari, full-width), the currency or unit in any spelling (€, EUR, euros), a leading or trailing minus, U+2212 and accounting parentheses. Grouping is strict: a group separator must sit before the decimal separator and be followed by a whole group, so 1,5 in English is not 15 and 1.5 in German is not 15; the text reverts instead of silently becoming ten times larger.
  • The committed value is rounded to what the text shows (the format's maximum fraction digits): 1.999 in a euro field commits 2 and shows 2,00 €, so what the user sees is what the form posts. Stepped values are rounded the same way (a step finer than the format cannot post a digit the text hides). Set maximumFractionDigits higher for more precision. notation: "compact" cannot be parsed back and logs a development warning.

Constraints: not clamped, reported

Like a native number input, a typed value is neither clamped to min / max nor snapped to step: it stays as typed, the hidden input reports rangeUnderflow, rangeOverflow or stepMismatch, the browser's localised message is mirrored onto the visible input, and the form does not submit until it is fixed. Stepping does clamp and snap, with the HTML stepUp() rules: an off-grid value snaps in the direction of the step, the step base is min (else 0; give min when the grid matters), and a button is disabled when its next step would change nothing (judged from the draft while one is open; an unreachable max stays unreachable: 0–5 by 2 stops at 4). To clamp typed values, do it in a controlled parent's valueChangeAction.

Anatomy

import { NumberField } from "@funstack/ui/number-field";

<NumberField.Root name="quantity" defaultValue={1} min={1} max={99} locale="en-US"
  valueCommitAction={saveQuantity}>             {/* div role=group, named by the Label */}
  <NumberField.Label>Quantity</NumberField.Label> {/* label for= the visible input */}
  <NumberField.Decrement>−</NumberField.Decrement> {/* button tabindex=-1 aria-label="Decrease" */}
  <NumberField.Input />                         {/* input type=text + hidden input type=number */}
  <NumberField.Increment>+</NumberField.Increment> {/* button tabindex=-1 aria-label="Increase" */}
</NumberField.Root>

Props

NumberField.Root

PropTypeDefaultDescription
valuenumber | null–Controlled value; null is empty, undefined uncontrolled. NaN / Infinity are treated as empty (development warning).
defaultValuenumber | nullnullInitial value in uncontrolled mode; also the form-reset target (the latest value).
valueChangeActionChangeAction<number | null, NumberFieldValueChangeDetails>–Runs in the Root's step transition on every step. Awaited; data-pending meanwhile. Adopt the value here in a controlled parent. Not the place for a save.
valueCommitActionChangeAction<number | null, NumberFieldValueChangeDetails>–Runs in the Root's commit transition once per gesture. Awaited; the field keeps the gesture's value with data-pending until it settles. Uncontrolled, the value is committed when it resolves and reverts when it rejects. May be a Server Function.
min / maxnumber–Native constraints on the hidden input, and the bounds of stepping. Home / End go to them when set.
stepnumber1 (0.01 for style "percent")Native step and the stepping grid from min (else 0).
largeStepnumber10 × stepPageUp / PageDown and Shift+Arrow.
formatIntl.NumberFormatOptions–How the text is formatted and parsed: decimal, percent, currency, unit, fraction digits, grouping, sign display, numbering system.
localestring | readonly string[]runtime localeBCP 47 tag(s) for formatting and parsing. Pass the page's lang (development warning when it disagrees and this is absent).
stepOnWheelbooleanfalseStep with the mouse wheel while the input is focused (a native non-passive listener; the page does not scroll then).
name / formstring–On the hidden number input, which posts String(value) ("" when empty). name defaults to the Field's.
disabledbooleanfalseDisables both inputs (not submitted) and the buttons.
readOnlybooleanfalseNative readonly on both inputs; no stepping, buttons disabled.
requiredbooleanfalseOn the hidden input: an empty field is valueMissing. Or the Field's.
announce(value: number | null, state: NumberFieldState) => string | nullstate.textWhat is announced after a key press, a wheel event, a click activation and a hold's release (not per tick, not for typed text). null announces nothing.
classNamestring | ((state: NumberFieldState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: NumberFieldState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: NumberFieldState) => ReactNode)–The parts. The function form receives the state.
renderReactElement | ((props, state: NumberFieldState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

NumberFieldState (every part but the buttons): { value, text, pending, disabled, readOnly, required }, where value is the shown value (the gesture's while one is open or in flight) and text the shown text (the draft while editing). Details: { reason, source, event }, reason being "activate", "platform" (a form reset, text found at hydration) or "dismiss" (the revert after a failed save, steps only), and source one of "input", "increment", "decrement", "keyboard", "wheel", or null on platform paths and the revert. event is the DOM event, and undefined for the repeats of a held button, a hold that ended at its bound, hydration and the revert. The Root owns role="group" and aria-labelledby (your tokens, then the Label's id, then a Field label's). There is no transition prop on any part; the dispatches carry the types fsui-number-field plus fsui-number-field-change (step) or fsui-number-field-commit (gesture) for your own boundaries.

NumberField.Input

PropTypeDefaultDescription
hiddenInputRefRef<HTMLInputElement>–The hidden native <input type="number"> rendered after the visible input (ref is the visible one).
inputMode / autoComplete / autoCorrect / spellCheckstring / boolean"numeric" or "decimal", "off", "off", falseinputMode is "decimal" when fractions are allowed, else "numeric". Defaults your own prop replaces.
classNamestring | ((state: NumberFieldState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: NumberFieldState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
renderReactElement | ((props, state: NumberFieldState) => ReactNode)–Replace the default <input> (element form) or take over rendering (function form).

The visible input owns id, type (text, or search / tel from a render element; a number element logs a warning), value, disabled, readOnly and its handlers. name, min, max, step, required and pattern are not props: constraints live on the hidden input, and a constraint on the visible one would make it invalid for a reason nobody reports. placeholder, aria-*, onFocus, onPaste, onCompositionStart / onCompositionEnd and the rest pass through.

NumberField.Increment, NumberField.Decrement

PropTypeDefaultDescription
disabledbooleanfalseAlso disabled by the Root's disabled / readOnly, and when the next step would change nothing.
aria-labelstring"Increase" / "Decrease"The built-in English names. Your aria-label (or aria-labelledby) replaces them; pass the same text as aria-label to let a text label name the button.
classNamestring | ((state: NumberFieldButtonState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: NumberFieldButtonState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: NumberFieldButtonState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: NumberFieldButtonState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

NumberFieldButtonState: { pending, disabled }, where pending is true while the latest step or commit came from this button and is in flight. The buttons own type="button" (on a <button> only), tabIndex={-1}, aria-controls and disabled.

NumberField.Label

PropTypeDefaultDescription
classNamestring | ((state: NumberFieldState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: NumberFieldState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: NumberFieldState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: NumberFieldState) => ReactNode)–Replace the default <label> (element form) or take over rendering (function form).

Must stay a <label>: it names the visible input through for and, through its id, the Root group.

Keyboard, buttons and wheel

InputEffect
ArrowUp / ArrowDown± step from the parsed draft or the shown value (empty counts as 0), snapped and clamped.
Shift+Arrow, PageUp / PageDown± largeStep.
Home / Endmin / the largest step at or below max, only when that bound is set; otherwise the caret moves. Shift+Home / Shift+End always select text.
EnterCommits the draft. Not prevented: inside a form with a submit button the implicit submission follows and posts the committed value.
Everything elseText editing. Tab leaves the field and the blur commits.
A button pressOne step at once, then a repeat after 400 ms and every 60 ms while held; one commit at the release.
The wheel (stepOnWheel)One step per wheel event while the input is focused; unfocused, the page scrolls.
  • Each key press, wheel event and click activation is its own gesture: a step and a commit. With a slow valueCommitAction a wheel flick is several commits; the latest wins.
  • A press on a button is preventDefault()ed: focus stays where it was (the library never moves it), a touch opens no keyboard, and no text is selected. The buttons are not Tab stops (tabindex="-1", as the APG asks); a click with no pointer (assistive technology) steps once.
  • The iPhone's numeric keypads have no minus key; pass inputMode="text" when negatives must be typed there.

Accessibility

  • No role="spinbutton". On a text input VoiceOver announces the wrong value for a spinbutton and its gestures rewrite the text, so the visible input is a plain textbox (as in React Aria and Base UI). The formatted text is its value, the group tells the user what the buttons change, and each step is announced.
  • Announcements go through ariaNotify() on the input, with a visually hidden role="status" region in document.body as the fallback where it is missing. The default is the formatted text ("2", "1,50 €", "50%"); override with announce.
  • No aria-roledescription: it would replace "edit text", the cue that the field takes typing. Pass one through NumberField.Input if you want it.
  • The hidden number input is aria-hidden and unfocusable; its invalid event is always cancelled, so the browser reports on (and focuses) the visible input, which mirrors its validationMessage.

Data attributes and CSS

AttributeOnMeaning
data-pendingRoot, Label, InputA step (with a valueChangeAction) or commit is in flight.
data-pendingIncrement / DecrementThe latest step or commit came from this button and is in flight.
data-disabledevery partDisabled (the buttons also at their bound and when read-only).
data-invalidRoot, InputInside a Field that shows an error.
:user-invalid, :read-only, :disabled, :placeholder-shownthe visible inputThe platform's own states.
:activea held buttonNo data-holding: the platform has it.
.number-field { display: inline-grid; grid-template-columns: auto 1fr auto; }
.number-field .input { min-width: 6ch; text-align: end; font-variant-numeric: tabular-nums; }
.number-field .step { inline-size: 2.25rem; }
.number-field .step:disabled { opacity: .4; }
.number-field .input[data-pending] { background-image: var(--spinner); background-repeat: no-repeat; }
.number-field .step[data-pending] { cursor: progress; }
.number-field .input:user-invalid { border-color: var(--danger); }
.number-field[data-invalid] .step { border-color: var(--danger); }
.number-field:has(.input:focus-visible) { outline: 2px solid var(--accent); }

The buttons' order is your markup's: an RTL page places Decrement first in source if it wants it at the inline start.

Forms and hydration

  • The hidden input posts String(value) (quantity=12, price=12.5), "" when empty, nothing when disabled; the text is never posted.
  • Form reset is the platform's for the hidden input (its defaultValue is the reset target: the latest defaultValue uncontrolled, the mount-time value controlled); the library then dispatches one step and one commit with reason: "platform" and clears an open draft.
  • Before hydration the text can be typed; at hydration a parsable change is adopted with one "platform" step and commit (or kept as the draft while the input is still focused). A form submitted before hydration posts the default value; the buttons do nothing yet. A plain <input type="number"> from a Server Component remains the JS-free number field.
  • A step writes the hidden input by script, so a <form onChange> listener hears typed edits only.

Inside a Field

Sold in packs of six, up to 120.

Show code
NumberFieldField.tsx
"use client";
import { startTransition, useState } from "react";
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import { NumberField } from "@funstack/ui/number-field";
import { fakeSave } from "./fake-api.js";

// A NumberField as a Field's control. `min`, `max` and `step` are native constraints on the hidden
// number input: a typed 7 is kept as typed and reported (`validity.stepMismatch`), not snapped.
// The browser's message is mirrored onto the visible input, so `:user-invalid`, the blocked
// submission and the focus move all land there. A + press snaps to the grid and clears it.
export function NumberFieldField() {
  const [saved, setSaved] = useState<string | null>(null);
  return (
    <Form.Root
      className="demo-form"
      autoReset={false}
      action={async (formData) => {
        await fakeSave("the order");
        startTransition(() => setSaved(String(formData.get("quantity"))));
      }}
    >
      <Field.Root name="quantity" className="demo-fieldset">
        <Field.Label className="demo-label">Quantity</Field.Label>
        <NumberField.Root
          className="demo-number-field"
          locale="en-US"
          min={6}
          max={120}
          step={6}
          defaultValue={6}
        >
          <div className="demo-number-group">
            <NumberField.Decrement className="demo-number-step">−</NumberField.Decrement>
            <NumberField.Input className="demo-number-input" />
            <NumberField.Increment className="demo-number-step">+</NumberField.Increment>
          </div>
        </NumberField.Root>
        <Field.Description className="demo-description">
          Sold in packs of six, up to 120.
        </Field.Description>
        <Field.Error className="demo-field-error">
          {({ validity, messages }) =>
            validity.stepMismatch ? "Enter a multiple of six." : messages.join(" ")
          }
        </Field.Error>
      </Field.Root>
      <div className="demo-row">
        <Form.Submit className="demo-button demo-primary demo-submit">
          {({ pending }) => (
            <>
              {pending && <span className="demo-spinner" aria-hidden />}
              Order
            </>
          )}
        </Form.Submit>
      </div>
      {saved !== null && <output className="demo-readout">Ordered {saved}.</output>}
    </Form.Root>
  );
}
number-field.css
/* NumberField: the Root is the group (a grid of the two buttons around the input). The visible
   input is a plain text field; the hidden number input after it is positioned by the library. */
.demo-number-field {
  display: grid;
  gap: 0.35rem;
  justify-items: start;
}

.demo-number-label {
  font-weight: 600;
  color: var(--color-text);
}

.demo-number-group {
  display: inline-grid;
  grid-template-columns: auto 1fr auto;
  align-items: stretch;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  overflow: hidden;
  background: var(--color-bg);
}

.demo-number-group:has(.demo-number-input:focus-visible) {
  outline: 2px solid var(--color-primary);
  outline-offset: 1px;
}

.demo-number-input {
  inline-size: 9ch;
  min-inline-size: 6ch;
  border: 0;
  padding: 0.4rem 0.6rem;
  background: transparent;
  color: var(--color-text);
  font: inherit;
  font-variant-numeric: tabular-nums;
  text-align: end;
}

.demo-number-input:focus {
  outline: none;
}

.demo-number-input:user-invalid {
  color: #dc2626;
}

.demo-number-input[data-pending] {
  color: var(--color-text-secondary);
}

.demo-number-step {
  inline-size: 2.25rem;
  border: 0;
  background: var(--color-bg-secondary, transparent);
  color: var(--color-text);
  font: inherit;
  font-size: 1.1rem;
  cursor: pointer;
}

.demo-number-step:active {
  background: var(--color-border);
}

.demo-number-step:disabled {
  opacity: 0.4;
  cursor: default;
}

.demo-number-step[data-pending] {
  cursor: progress;
}

.demo-number-field[data-invalid] .demo-number-group,
.demo-number-group:has(:user-invalid) {
  border-color: #dc2626;
}

.demo-number-status {
  color: var(--color-text-secondary);
  font-size: 0.875rem;
}
  • The visible input is the Field's control: it takes the Field's id (so Field.Label's for names it), its description and error, and the focus of a blocked submission. The Root's group is named by Field.Label too.
  • The validity is the hidden input's: Field.Error's function children sees validity.stepMismatch, rangeUnderflow, rangeOverflow and valueMissing, and the Field writes validate's message and server errors there, mirrored onto the visible input.
  • name and required go to the hidden input, disabled to both inputs and the buttons. validate receives String(number) (the committed value, not the draft), so Number(value) is safe.
  • A step or a typed commit counts as an edit (it re-runs validate and dismisses a server error); a rollback or a form reset re-runs validate only.
  • The two channels stay separate: the field's own save is its data-pending, the form's submission is the Field's.
// A Server Function as the save, straight from a Server Component:
<NumberField.Root defaultValue={line.quantity} min={1} locale="en-US" valueCommitAction={saveQuantity}>…</NumberField.Root>