Fieldset
Groups related form controls under a legend.
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/fieldRequires 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 (throughfield.Context, so custom controls such as switches are disabled too, not only native inputs). Nestedfieldset.Contexts inherit it. Defaultfalse.
fieldset.root(): the root. On a native<fieldset>it setsdisabled. On any other element it setsrole="group"andaria-disabled. It setsaria-labelledbyto 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) anddata-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
disabledbelongs to the Context, not to the host: a hostdisabledprop 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-labelledbyfor it. It's added right after hydration. Prefer a native<legend>.
Styling hooks
| Part | Attribute | Values |
|---|---|---|
root | data-disabled | Present when the fieldset is disabled. |
legend | data-disabled | Present 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 UIField.Rootprops)name: identifies the field in a form. Wins over the control'sname. Keysform.Contexterrors.disabled: disables the field and its control. Wins over the control'sdisabled. An enclosingfieldset.Contextwithdisabledalso 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 withsetCustomValidity().validationMode:'onSubmit'(default; validate on form submit, then on every change),'onBlur'(on blur, and on change while invalid),'onChange'(on every change). Wins overform.Context'svalidationMode.validationDebounceTime: debounce for'onChange'validation, in ms. Default0.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, setsforto 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'saria-describedby.field.error(options?): an error message,hiddenunless it matches.match: omitted → shown when the field is invalid; aValidityStatekey ('valueMissing','typeMismatch','patternMismatch','tooShort','tooLong','rangeUnderflow','rangeOverflow','stepMismatch','badInput','customError') → shown when invalid for that reason ('customError'coversvalidateand 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 asinput()fromstems-ui/input(Base UI'sField.ControlandInputare 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 UIField.Item): one item of a checkbox group or radio group. Afield.label()/field.description()inside it labels / describes the item's control (thecheckbox.Contextorradio.Contextinside it):<label for>targets the item's control id, the control getsaria-labelledby/aria-describedby.disableddisables the item's control (the field'sdisabledwins).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, withdata-disabledincluding the item'sdisabled.<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'sField.Validity.state:{ validity, errors, error, value, initialValue, transitionStatus }.validityhas theValidityStateflags plusvalid: 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
validisnulluntil the field is validated, so neitherdata-validnordata-invalidis rendered before the first validation (unlessinvalidor server errors apply).- Native constraints (
required,pattern,min,type="email"…) run first;validateruns only when they pass. Custom errors are set withsetCustomValidity(), so:invalidandform.checkValidity()agree with the field. - Inside a plain
<form>(withoutform.root()), the browser's own validation still runs on submit; the root listens for the control'sinvalidevent and shows the field's errors. - Async
validateresults 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'saria-describedbydrops 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>-controlunless it has its ownid, which the label then follows. On the server, ids of parts that render after the control (descriptions, errors) can't be in itsaria-describedbyyet. The field fixes it right after hydration. - Host props win over the field: a host
name,disabled,id, oraria-describedbyon the control replaces the field's value. - Inside a
field.ItemContext,field.label()andfield.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):
| Attribute | Values |
|---|---|
data-disabled | Present when the field is disabled. |
data-valid | Present when the field was validated and is valid. |
data-invalid | Present when the field is invalid. |
data-touched | Present after the control lost focus once. |
data-dirty | Present when the value differs from the initial value. |
data-filled | Present when the control has a value. |
data-focused | Present 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.