Stems UI
Menu

Fieldset

Groups related form controls under a legend.

Billing address

Where we send your invoices.

Codeapp/demos/public/fieldset.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { Button } from '@stemsui/registry/ui/button.tsx'
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from '@stemsui/registry/ui/field.tsx'
import { Input } from '@stemsui/registry/ui/input.tsx'

export const FieldsetDemo = clientEntry(import.meta.url, function FieldsetDemo(handle: Handle) {
  let locked = false

  return () => (
    <div class="flex w-full max-w-md flex-col gap-6">
      <FieldSet disabled={locked} data-testid="fieldset-billing">
        <FieldLegend>Billing address</FieldLegend>
        <FieldDescription>Where we send your invoices.</FieldDescription>
        <FieldGroup>
          <Field>
            <FieldLabel>Street</FieldLabel>
            <Input placeholder="12 Analytical Way" />
          </Field>
          <div class="grid grid-cols-2 gap-4">
            <Field>
              <FieldLabel>City</FieldLabel>
              <Input placeholder="London" />
            </Field>
            <Field>
              <FieldLabel>Postal code</FieldLabel>
              <Input placeholder="SW1A 1AA" />
            </Field>
          </div>
        </FieldGroup>
      </FieldSet>

      {/* Toggles the fieldset's `disabled`, which propagates to every field inside. */}
      <Button
        variant="outline"
        type="button"
        class="self-start"
        data-testid="fieldset-toggle"
        mix={on('click', () => {
          locked = !locked
          void handle.update()
        })}
      >
        {locked ? 'Unlock address' : 'Lock address'}
      </Button>
    </div>
  )
})

Installation

npx shadcn add @stemsui/field

Requires the one-time setup. Behavior: stems-ui/fieldset, 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/fieldset

fieldset groups related fields under a shared legend, and disables them together.

Base UI equivalent: Fieldset.

Usage

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

;<fieldset.Context disabled={locked}>
  <fieldset mix={fieldset.root()}>
    <legend mix={fieldset.legend()}>Billing address</legend>
    <field.Context name="street">
      <label mix={field.label()}>Street</label>
      <input mix={field.control()} />
    </field.Context>
  </fieldset>
</fieldset.Context>

stems-ui/fieldset

  • <fieldset.Context>
    • disabled: disables the fieldset and every field inside it (through field.Context, so custom controls such as switches are disabled too, not only native inputs). Nested fieldset.Contexts inherit it. Default false.
  • fieldset.root(): the root. On a native <fieldset> it sets disabled. On any other element it sets role="group" and aria-disabled. It sets aria-labelledby to the legend's id unless the legend is a native <legend> inside a native <fieldset>, which already names it.
  • fieldset.legend(): the legend. Gets an id (or keeps its own) and data-disabled. Use a native <legend> as the fieldset's first child, or any element (Base UI renders a <div>).

Descendants can read { disabled, legendId } with handle.context.get(fieldset.Context).

Behavior notes

  • disabled belongs to the Context, not to the host: a host disabled prop on the <fieldset> disables native controls but isn't seen by the fields inside.
  • A non-native legend is only known after it renders, so server HTML has no aria-labelledby for it. It's added right after hydration. Prefer a native <legend>.

Styling hooks

PartAttributeValues
rootdata-disabledPresent when the fieldset is disabled.
legenddata-disabledPresent when the fieldset is disabled.

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.