Form
A native form with consolidated validation errors.
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/formRequires 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 UIFormprops)errors:{ [fieldName]: string | string[] }, typically returned by the server. Each named field shows its errors (ascustomError,aria-invalid,data-invalid) until its value changes. Passing a newerrorsobject 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 ownvalidationModewins.onFormSubmit(values, { reason: 'none', event }): called with the named fields' values when a valid form is submitted. Providing it callspreventDefault(), 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 namedname.
form.root(): the native<form>.onFormSubmit(handler): listens forstems:form-submit(FormSubmitEventwithvalues), 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 nonovalidate, so without JavaScript the browser's constraint validation runs and the form submits to itsactionas usual. Rendererrorsfrom the action's result and the fields show them in the server HTML. Once hydrated,form.root()setsnoValidate(unless the host setsnoValidateitself) 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 ispreventDefault()ed, its propagation stops (on('submit')handlers mixed in afterform.root()only see valid submissions), and the first invalid control in document order is focused. - Controls that are not inside a
field.Contextare 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
errorsnever block a submission: the server validates again. - Async
validatefunctions 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 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.