Stems UI
Menu

Field

Labels, descriptions, and validation for a form control.

Visible on your profile. Validates when you leave the field.

Validated as you type (debounced).

The field disables its label, control, and description.

Every control in a field

Every Stems control plugs into a Field: labels, descriptions, validation and server errors. Submit empty to see required errors; each radio and checkbox in a group gets its own label through field.ItemContext.

Pick Enterprise to see a server error.

Storage

Fast, for databases.

Cheap, for archives.

Sold out.

Allowed protocols
web

Free text, with suggestions.

Scaling threshold
Codeapp/demos/public/field.tsx
import { clientEntry, type Handle } from 'remix/component'
import * as checkboxGroup from 'stems-ui/checkbox-group'
import * as field from 'stems-ui/field'
import {
  Autocomplete,
  AutocompleteContent,
  AutocompleteEmpty,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
} from '@stemsui/registry/ui/autocomplete.tsx'
import { Button } from '@stemsui/registry/ui/button.tsx'
import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
} from '@stemsui/registry/ui/combobox.tsx'
import { Checkbox } from '@stemsui/registry/ui/checkbox.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'
import { NumberField } from '@stemsui/registry/ui/number-field.tsx'
import { OTPField, OTPFieldGroup, OTPFieldSlot } from '@stemsui/registry/ui/otp-field.tsx'
import { RadioGroup, RadioGroupItem } from '@stemsui/registry/ui/radio-group.tsx'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@stemsui/registry/ui/select.tsx'
import { Switch } from '@stemsui/registry/ui/switch.tsx'
import { Slider } from '@stemsui/registry/ui/slider.tsx'

const plans = [
  { value: 'hobby', label: 'Hobby' },
  { value: 'pro', label: 'Pro' },
  { value: 'enterprise', label: 'Enterprise' },
]

const storageTypes = [
  { value: 'ssd', label: 'SSD', description: 'Fast, for databases.' },
  { value: 'hdd', label: 'HDD', description: 'Cheap, for archives.' },
  { value: 'tape', label: 'Tape', description: 'Sold out.', disabled: true },
]

const protocols = ['http', 'https', 'ssh']

const regions = ['us-east-1', 'us-west-2', 'eu-west-1', 'eu-central-1', 'ap-southeast-1']

const tags = ['web', 'api', 'worker', 'cron', 'database', 'cache']

const images = ['nginx:1.29-alpine', 'node:22-slim', 'postgres:18', 'redis:8.2-alpine']

// Stands in for a server action: the Enterprise plan "fails" on the server.
async function launch(values: FormValues): Promise<FormErrors> {
  await new Promise((resolve) => setTimeout(resolve, 400))
  if (values.plan === 'enterprise') return { plan: 'Enterprise needs a sales call first.' }
  return {}
}

function ControlsDemo(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 launch(values)
        pending = false
        if (Object.keys(errors).length === 0) result = JSON.stringify(values)
        void handle.update()
      }}
      data-testid="field-controls-form"
    >
      <FieldGroup>
        <Field name="plan">
          <FieldLabel>Plan</FieldLabel>
          <Select items={plans} required>
            <SelectTrigger class="w-48" data-testid="field-plan">
              <SelectValue placeholder="Choose a plan" />
            </SelectTrigger>
            <SelectContent>
              {plans.map((plan) => (
                <SelectItem value={plan.value}>{plan.label}</SelectItem>
              ))}
            </SelectContent>
          </Select>
          <FieldDescription>Pick Enterprise to see a server error.</FieldDescription>
          <FieldError match="valueMissing">Choose a plan.</FieldError>
          <FieldError match="customError" data-testid="field-plan-error" />
        </Field>

        <Field name="storage">
          <div class="text-sm leading-snug font-medium" mix={field.label()}>
            Storage
          </div>
          <RadioGroup required class="gap-3" data-testid="field-storage">
            {storageTypes.map((type) => (
              <field.ItemContext disabled={type.disabled}>
                <div class="flex items-start gap-3 data-disabled:opacity-50" mix={field.item()}>
                  <RadioGroupItem value={type.value} class="mt-0.5" />
                  <div class="flex flex-col gap-1">
                    <FieldLabel class="font-normal">{type.label}</FieldLabel>
                    <FieldDescription>{type.description}</FieldDescription>
                  </div>
                </div>
              </field.ItemContext>
            ))}
          </RadioGroup>
          <FieldError match="valueMissing">Choose a storage type.</FieldError>
        </Field>

        <Field
          name="protocols"
          validate={(value) =>
            (value as string[]).length === 0 ? 'Allow at least one protocol.' : null
          }
        >
          <div class="text-sm leading-snug font-medium" mix={field.label()}>
            Allowed protocols
          </div>
          <checkboxGroup.Context>
            <div class="flex gap-6" mix={checkboxGroup.root()} data-testid="field-protocols">
              {protocols.map((protocol) => (
                <field.ItemContext>
                  <div class="flex items-center gap-2" mix={field.item()}>
                    <Checkbox value={protocol} />
                    <FieldLabel class="font-normal uppercase">{protocol}</FieldLabel>
                  </div>
                </field.ItemContext>
              ))}
            </div>
          </checkboxGroup.Context>
          <FieldError data-testid="field-protocols-error" />
        </Field>

        <Field name="region">
          <FieldLabel>Region</FieldLabel>
          <Combobox items={regions} required>
            <ComboboxInput placeholder="Search regions" showClear data-testid="field-region" />
            <ComboboxContent>
              <ComboboxEmpty>No region found.</ComboboxEmpty>
              <ComboboxList>
                {(region: string) => (
                  <ComboboxItem key={region} value={region}>
                    {region}
                  </ComboboxItem>
                )}
              </ComboboxList>
            </ComboboxContent>
          </Combobox>
          <FieldError match="valueMissing">Choose a region.</FieldError>
        </Field>

        <Field
          name="tags"
          validate={(value) => ((value as string[]).length > 3 ? 'Use at most 3 tags.' : null)}
        >
          <FieldLabel>Tags</FieldLabel>
          <Combobox multiple items={tags} defaultValue={['web']}>
            <ComboboxChips data-testid="field-tags">
              <ComboboxValue>
                {(values: string[]) =>
                  values.map((value) => (
                    <ComboboxChip key={value} value={value}>
                      {value}
                    </ComboboxChip>
                  ))
                }
              </ComboboxValue>
              <ComboboxChipsInput placeholder="Add a tag" />
            </ComboboxChips>
            <ComboboxContent>
              <ComboboxEmpty>No tag found.</ComboboxEmpty>
              <ComboboxList>
                {(tag: string) => (
                  <ComboboxItem key={tag} value={tag}>
                    {tag}
                  </ComboboxItem>
                )}
              </ComboboxList>
            </ComboboxContent>
          </Combobox>
          <FieldError data-testid="field-tags-error" />
        </Field>

        <Field name="image">
          <FieldLabel>Image</FieldLabel>
          <Autocomplete items={images} required>
            <AutocompleteInput placeholder="e.g. nginx:1.29-alpine" data-testid="field-image" />
            <AutocompleteContent>
              <AutocompleteEmpty>No suggestions. Any image works.</AutocompleteEmpty>
              <AutocompleteList>
                {(image: string) => (
                  <AutocompleteItem key={image} value={image}>
                    {image}
                  </AutocompleteItem>
                )}
              </AutocompleteList>
            </AutocompleteContent>
          </Autocomplete>
          <FieldDescription>Free text, with suggestions.</FieldDescription>
          <FieldError match="valueMissing">Enter an image.</FieldError>
        </Field>

        <Field name="instances">
          <FieldLabel>Instances</FieldLabel>
          <NumberField required min={1} max={64} placeholder="1–64" data-testid="field-instances" />
          <FieldError match="valueMissing">How many instances?</FieldError>
          <FieldError match="rangeUnderflow">At least 1.</FieldError>
          <FieldError match="rangeOverflow">At most 64.</FieldError>
        </Field>

        <Field
          name="threshold"
          validate={(value) => {
            let [low, high] = value as number[]
            return high! - low! < 20 ? 'Keep the thresholds at least 20% apart.' : null
          }}
        >
          <div class="text-sm leading-snug font-medium" mix={field.label()}>
            Scaling threshold
          </div>
          <Slider defaultValue={[40, 50]} data-testid="field-threshold" />
          <FieldError data-testid="field-threshold-error" />
        </Field>

        <Field name="code">
          <FieldLabel>Verification code</FieldLabel>
          <OTPField length={6} required data-testid="field-code">
            <OTPFieldGroup>
              {[0, 1, 2, 3, 4, 5].map((index) => (
                <OTPFieldSlot aria-label={index === 0 ? undefined : `Digit ${index + 1}`} />
              ))}
            </OTPFieldGroup>
          </OTPField>
          <FieldError match="valueMissing">Enter the code from your app.</FieldError>
          <FieldError match="patternMismatch">Enter all 6 digits.</FieldError>
        </Field>

        <Field name="restart" orientation="horizontal">
          <Switch defaultChecked data-testid="field-restart" />
          <FieldLabel class="font-normal">Restart on failure</FieldLabel>
        </Field>

        <Field name="terms" orientation="horizontal">
          <Checkbox required data-testid="field-terms" />
          <FieldLabel class="font-normal">I accept the terms</FieldLabel>
          <FieldError match="valueMissing" class="sr-only">
            Accept the terms.
          </FieldError>
        </Field>

        <div class="flex flex-col gap-3">
          <Button type="submit" class="w-fit" disabled={pending} data-testid="field-launch">
            {pending ? 'Launching…' : 'Launch server'}
          </Button>
          {result ? (
            <code class="text-xs break-all text-muted-foreground" data-testid="field-result">
              {result}
            </code>
          ) : null}
        </div>
      </FieldGroup>
    </Form>
  )
}

export const FieldDemo = clientEntry(import.meta.url, function FieldDemo(_handle: Handle) {
  return () => (
    <div class="flex w-full max-w-sm flex-col gap-12">
      <FieldGroup class="w-full max-w-sm">
        <Field name="name" validationMode="onBlur">
          <FieldLabel>Name</FieldLabel>
          <Input required placeholder="Ada Lovelace" data-testid="field-name" />
          <FieldDescription>
            Visible on your profile. Validates when you leave the field.
          </FieldDescription>
          <FieldError match="valueMissing" data-testid="field-name-error">
            Please enter your name.
          </FieldError>
        </Field>

        <Field
          name="username"
          validationMode="onChange"
          validationDebounceTime={300}
          validate={(value) =>
            String(value).length < 3 ? 'Usernames need at least 3 characters.' : null
          }
        >
          <FieldLabel>Username</FieldLabel>
          <Input placeholder="ada" data-testid="field-username" />
          <FieldDescription>Validated as you type (debounced).</FieldDescription>
          <FieldError data-testid="field-username-error" />
        </Field>

        <Field name="email" validationMode="onBlur">
          <FieldLabel>Email</FieldLabel>
          <Input type="email" placeholder="you@example.com" data-testid="field-email" />
          <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
        </Field>

        <Field disabled>
          <FieldLabel>Disabled</FieldLabel>
          <Input placeholder="Not editable" />
          <FieldDescription>
            The field disables its label, control, and description.
          </FieldDescription>
        </Field>
      </FieldGroup>

      <section class="flex flex-col gap-4">
        <div class="flex flex-col gap-1">
          <h3 class="text-sm font-semibold">Every control in a field</h3>
          <p class="text-sm text-muted-foreground">
            Every Stems control plugs into a Field: labels, descriptions, validation and server
            errors. Submit empty to see required errors; each radio and checkbox in a group gets its
            own label through <code>field.ItemContext</code>.
          </p>
        </div>
        <ControlsDemo />
      </section>
    </div>
  )
})

Installation

npx shadcn add @stemsui/field

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

API reference

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

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.