Stems UI
Menu

Form

A native form with consolidated validation errors.

Lowercase letters and numbers. Try "admin" or "ada" for a server error.

Codeapp/demos/public/form.tsx
import { clientEntry, type Handle } from 'remix/component'
import { Button } from '@stemsui/registry/ui/button.tsx'
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@stemsui/registry/ui/field.tsx'
import { Form, type FormErrors, type FormValues } from '@stemsui/registry/ui/form.tsx'
import { Input } from '@stemsui/registry/ui/input.tsx'

// Stands in for a server action: the request "fails" for some usernames.
async function submitToServer(values: FormValues): Promise<FormErrors> {
  await new Promise((resolve) => setTimeout(resolve, 600))
  let username = String(values.username ?? '')
  if (username === 'admin') return { username: `'admin' is reserved for system use.` }
  if (username.startsWith('ada')) return { username: `${username} is already taken.` }
  return {}
}

export const FormDemo = clientEntry(import.meta.url, function FormDemo(handle: Handle) {
  let errors: FormErrors = {}
  let pending = false
  let result: string | null = null

  return () => (
    <Form
      class="w-full max-w-sm"
      errors={errors}
      onClearErrors={(next) => {
        errors = next
      }}
      onFormSubmit={async (values) => {
        pending = true
        result = null
        void handle.update()
        errors = await submitToServer(values)
        pending = false
        if (Object.keys(errors).length === 0) result = `Saved ${String(values.username)}.`
        void handle.update()
      }}
      data-testid="form-demo"
    >
      <FieldGroup>
        <Field name="username">
          <FieldLabel>Username</FieldLabel>
          <Input
            required
            minLength={3}
            pattern="[a-z0-9]+"
            placeholder="ada"
            data-testid="form-username"
          />
          <FieldDescription>
            Lowercase letters and numbers. Try "admin" or "ada" for a server error.
          </FieldDescription>
          <FieldError match="valueMissing">Choose a username.</FieldError>
          <FieldError match="tooShort">Use at least 3 characters.</FieldError>
          <FieldError match="patternMismatch">Only lowercase letters and numbers.</FieldError>
          <FieldError match="customError" data-testid="form-username-server-error" />
        </Field>

        <Field
          name="age"
          validate={(value) =>
            value === '' || Number(value) >= 13 ? null : 'You must be at least 13.'
          }
        >
          <FieldLabel>Age</FieldLabel>
          <Input type="number" placeholder="36" data-testid="form-age" />
          <FieldError data-testid="form-age-error" />
        </Field>

        <div class="flex items-center gap-3">
          <Button type="submit" disabled={pending} data-testid="form-submit">
            {pending ? 'Submitting…' : 'Submit'}
          </Button>
          {result ? (
            <p class="text-sm text-muted-foreground" data-testid="form-result">
              {result}
            </p>
          ) : null}
        </div>
      </FieldGroup>
    </Form>
  )
})

Installation

npx shadcn add @stemsui/form

Requires the one-time setup. Behavior: stems-ui/form, stems-ui/field.

API reference

The styled component is built on the headless modules below: its props mirror these options, and the same events and data attributes apply.

stems-ui/form

form wires a native <form> to its fields: it validates every field on submit, blocks invalid submissions and focuses the first invalid control, and shows errors returned by the server on the matching fields.

Base UI equivalent: Form.

Usage

import * as field from 'stems-ui/field'
import * as form from 'stems-ui/form'

// `errors` typically comes from the server action's result, keyed by field name.
;<form.Context errors={actionResult?.errors}>
  <form method="post" action="/signup" mix={form.root()}>
    <field.Context name="username">
      <label mix={field.label()}>Username</label>
      <input required mix={field.control()} />
      <div mix={field.error()}>
        <field.Validity>{(state) => state.error}</field.Validity>
      </div>
    </field.Context>
    <button type="submit">Sign up</button>
  </form>
</form.Context>

Handling the submission on the client instead:

<form.Context
  errors={errors}
  onFormSubmit={async (values) => {
    errors = await save(values) // e.g. { username: 'Taken' }
    void handle.update()
  }}
>
  …
</form.Context>

stems-ui/form

  • <form.Context> (Base UI Form props)
    • errors: { [fieldName]: string | string[] }, typically returned by the server. Each named field shows its errors (as customError, aria-invalid, data-invalid) until its value changes. Passing a new errors object shows all of its errors again.
    • onClearErrors(errors): called with the remaining errors when a field clears its errors because its value changed.
    • validationMode: default for the fields inside: 'onSubmit' (default), 'onBlur', 'onChange'. A field's own validationMode wins.
    • onFormSubmit(values, { reason: 'none', event }): called with the named fields' values when a valid form is submitted. Providing it calls preventDefault(), so the browser and Remix navigation don't submit the form.
    • actionsRef: { current } or a callback receiving { validate(name?) }, which validates all fields or the fields named name.
  • form.root(): the native <form>.
  • onFormSubmit(handler): listens for stems:form-submit (FormSubmitEvent with values), dispatched from the form when a submission passes validation.

Descendants can read { errors, validationMode, submitAttempted, validate(name?), getValues() } with handle.context.get(form.Context).

Behavior notes

  • Server-first. The server-rendered <form> has no novalidate, so without JavaScript the browser's constraint validation runs and the form submits to its action as usual. Render errors from the action's result and the fields show them in the server HTML. Once hydrated, form.root() sets noValidate (unless the host sets noValidate itself) and validation moves to Stems: field errors instead of browser bubbles.
  • On submit, every field validates (marking it submitted, so 'onSubmit' fields re-validate on change after that). When any field is invalid the native event is preventDefault()ed, its propagation stops (on('submit') handlers mixed in after form.root() only see valid submissions), and the first invalid control in document order is focused.
  • Controls that are not inside a field.Context are still checked with native validation: an invalid one blocks the submission and shows the browser's message (reportValidity()).
  • Without onFormSubmit, a valid submission continues natively (and Remix navigation handles it).
  • External errors never block a submission: the server validates again.
  • Async validate functions never block a submission.

Styling hooks

form.root() adds no attributes. Style fields through field's data attributes.

stems-ui/field

field gives a form control a label, descriptions, and validation (native constraint validation plus a custom validate function), and exposes the field's state as data attributes on every part.

Base UI equivalent: Field.

Usage

import * as field from 'stems-ui/field'

;<field.Context name="email" validationMode="onBlur">
  <div mix={field.root()}>
    <label mix={field.label()}>Email</label>
    <input type="email" required mix={field.control()} />
    <p mix={field.description()}>We never share it.</p>
    <div mix={field.error({ match: 'valueMissing' })}>Enter your email.</div>
    <div mix={field.error({ match: 'typeMismatch' })}>
      <field.Validity>{(state) => state.error}</field.Validity>
    </div>
  </div>
</field.Context>

Custom validation (sync or async):

<field.Context
  name="username"
  validationMode="onChange"
  validationDebounceTime={300}
  validate={async (value) => ((await isTaken(value)) ? 'That name is taken' : null)}
>
  …
</field.Context>

Any Stems control integrates with a field the same way field.control() does (input, and the controls built on lib/field-control.ts): put it inside <field.Context> and it gets the field's id, name, disabled state, ARIA wiring, and validation.

stems-ui/field

  • <field.Context> (Base UI Field.Root props)

    • name: identifies the field in a form. Wins over the control's name. Keys form.Context errors.
    • disabled: disables the field and its control. Wins over the control's disabled. An enclosing fieldset.Context with disabled also disables it.
    • invalid: forces the invalid state (for external validation libraries).
    • dirty, touched: control those states (for external libraries).
    • validate(value, formValues): custom validation, run after native constraints pass. Return a message (or an array of messages) when invalid; null, '', [] or nothing when valid. May return a promise. Errors are applied with setCustomValidity().
    • validationMode: 'onSubmit' (default; validate on form submit, then on every change), 'onBlur' (on blur, and on change while invalid), 'onChange' (on every change). Wins over form.Context's validationMode.
    • validationDebounceTime: debounce for 'onChange' validation, in ms. Default 0.
    • actionsRef: { current } or a callback receiving { validate() }.
  • field.root(): the field's root element (any element; Base UI renders a <div>).

  • field.label(): on a <label> host, sets for to the control's id. Clicking a non-<label> host, or the label of a non-labelable control (a <span role="switch">), focuses the control.

  • field.description(): a description. Referenced by the control's aria-describedby.

  • field.error(options?): an error message, hidden unless it matches.

    • match: omitted → shown when the field is invalid; a ValidityState key ('valueMissing', 'typeMismatch', 'patternMismatch', 'tooShort', 'tooLong', 'rangeUnderflow', 'rangeOverflow', 'stepMismatch', 'badInput', 'customError') → shown when invalid for that reason ('customError' covers validate and server errors); 'valid' → shown when valid; true / false → always / never.
  • field.control(options?): the field's native <input>, <textarea> or <select>. This is the same mixin as input() from stems-ui/input (Base UI's Field.Control and Input are the same component too). Use one or the other on an element, not both. Options: value, defaultValue, onValueChange(value, event), disabled.

  • <field.ItemContext disabled?> (Base UI Field.Item): one item of a checkbox group or radio group. A field.label() / field.description() inside it labels / describes the item's control (the checkbox.Context or radio.Context inside it): <label for> targets the item's control id, the control gets aria-labelledby / aria-describedby. disabled disables the item's control (the field's disabled wins). handle.context.get(field.ItemContext) returns { state, disabled, controlId, labelId, describedBy }.

  • field.item(): the item's element (Base UI renders a <div>): the field state attributes, with data-disabled including the item's disabled.

    <field.Context name="storage">
      <radioGroup.Context>
        <div aria-label="Storage" mix={radioGroup.root()}>
          <field.ItemContext>
            <div mix={field.item()}>
              <label mix={field.label()}>
                <radio.Context value="ssd">
                  <span mix={radio.root()} />
                </radio.Context>
                SSD
              </label>
              <p mix={field.description()}>Faster</p>
            </div>
          </field.ItemContext>
        </div>
      </radioGroup.Context>
    </field.Context>
  • <field.Validity>{(state) => …}</field.Validity>: renders content from the field's validity, like Base UI's Field.Validity. state: { validity, errors, error, value, initialValue, transitionStatus }. validity has the ValidityState flags plus valid: boolean | null.

Reading state: descendants can call handle.context.get(field.Context) for { name, disabled, touched, dirty, valid, filled, focused, state, validity, validationMode, controlId, labelId, validate() }.

Behavior notes

  • valid is null until the field is validated, so neither data-valid nor data-invalid is rendered before the first validation (unless invalid or server errors apply).
  • Native constraints (required, pattern, min, type="email"…) run first; validate runs only when they pass. Custom errors are set with setCustomValidity(), so :invalid and form.checkValidity() agree with the field.
  • Inside a plain <form> (without form.root()), the browser's own validation still runs on submit; the root listens for the control's invalid event and shows the field's errors.
  • Async validate results never block a submission. Stale async results are ignored.
  • Errors use data-starting-style / data-ending-style: an error that stops matching stays shown until its exit animation finishes. The control's aria-describedby drops it at once.
  • Error messages are app content (a mixin can't render children). Render text, or <field.Validity> to show the current messages.
  • Ids: the control gets <fieldId>-control unless it has its own id, which the label then follows. On the server, ids of parts that render after the control (descriptions, errors) can't be in its aria-describedby yet. The field fixes it right after hydration.
  • Host props win over the field: a host name, disabled, id, or aria-describedby on the control replaces the field's value.
  • Inside a field.ItemContext, field.label() and field.description() label and describe the item's control instead of the field's (an item of an outer field doesn't capture the parts of a field nested inside it). Errors always belong to the field.

Styling hooks

All parts (root, label, description, error, item, the control):

AttributeValues
data-disabledPresent when the field is disabled.
data-validPresent when the field was validated and is valid.
data-invalidPresent when the field is invalid.
data-touchedPresent after the control lost focus once.
data-dirtyPresent when the value differs from the initial value.
data-filledPresent when the control has a value.
data-focusedPresent while the control has focus.

error also has data-starting-style / data-ending-style while it animates in or out. The control has aria-invalid="true" while invalid.