Skip to content

Field

  • Any form control that needs a visible label, a hint and an error message
  • Validation errors that should be read out with the control
  • Keeping label, description and error wired up without managing ids by hand

Not for a group of controls under one caption — use a native <fieldset> and <legend>, styled with the fieldset classes.

Choosing between entering text:

ComponentUse it forFor example
InputOne line of text: a name, an email, a searchEmail address, search box
OTP inputA code of fixed length, typed or pasted one character per boxSMS code, two-factor code, PIN
TextareaSeveral lines of free text that may wrapComment, description, message
ComboboxTyping to find and pick from a list of known optionsChoose a city, add existing tags
FieldNot a control: wraps any of these to tie a label, a hint and an error to itAny form control with validation

We send the receipt here.

Enter an email like ada@example.com.

Change it any time.

import { useState } from 'react';
import { Field, Select } from '@arun-dev/ui';
const stack = { display: 'grid', gap: 'var(--space-md)', maxInlineSize: '20rem' };
export default function FieldBasics() {
const [email, setEmail] = useState('ada@');
// Field validates nothing: invalid is yours — here, a simple check as you type.
const invalid = email !== '' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
return (
<div style={stack}>
<Field.Root invalid={invalid} required>
<Field.Label>Email</Field.Label>
<Field.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
/>
<Field.Description>We send the receipt here.</Field.Description>
<Field.Error>Enter an email like ada@example.com.</Field.Error>
</Field.Root>
{/* Any control, through render. */}
<Field.Root>
<Field.Label>Plan</Field.Label>
<Field.Control
render={
<Select defaultValue="pro">
<option value="free">Free</option>
<option value="pro">Pro</option>
</Select>
}
/>
<Field.Description>Change it any time.</Field.Description>
</Field.Root>
<Field.Root disabled>
<Field.Label>Company</Field.Label>
<Field.Control defaultValue="Analytical Engines Ltd" />
</Field.Root>
</div>
);
}

Field ties a label, help text and an error message to one control, so a screen reader reads them together. It generates the ids, and Field.Control puts them on the control.

import { Field } from '@arun-dev/ui';
<Field.Root invalid={invalid} required>
<Field.Label>Email</Field.Label>
<Field.Control type="email" />
<Field.Description>We send the receipt here.</Field.Description>
<Field.Error>Enter a valid email.</Field.Error>
</Field.Root>;
PartRendersDoes
Root<div>generates the ids; holds invalid, disabled and required
Label<label>names the control; a press focuses it
Controlthe controlan Input by default; gets the id, aria-describedby, aria-invalid, disabled and required
Description<p>help text, read out with the control
Error<p>shown only while invalid, and read out with the control then

Field.Control renders an Input unless render gives another. The Field’s attributes are merged onto it, so it works with every control in the system and with your own:

<Field.Control render={<Select>…</Select>} />
<Field.Control render={<Textarea rows={4} />} />
<Field.Control render={<Checkbox.Root />} />
<Field.Control render={<Combobox.Input />} />

The id is the Field’s. Don’t give the control an id of your own, or the label loses it.

Field checks nothing. Set invalid from wherever you validate:

  • react-hook-form: invalid={!!fieldState.error}, and the message in Field.Error. See a whole form with every control.
  • The browser: required on the Root makes the control required, so the browser stops the form and shows its own message.

A group of controls under one caption, such as radios or checkboxes, is a native <fieldset> with a <legend>. The browser names the group by its legend, and disabled on the fieldset disables every control in it. Field ties a label to one control, so it is not used here; four classes draw the group as a Field is drawn, from the same --field-* tokens:

ClassOnDrawn as
.fieldsetthe <fieldset>a Field, unframed
.fieldset-legendits <legend>Field.Label
.fieldset-descriptiona <p> inside itField.Description
.fieldset-errora <p> inside itField.Error
Notify me by

Pick one or more.

import { useState } from 'react';
import { Button, Checkbox } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
const options = ['Email', 'SMS', 'Push'];
// A group of controls under one caption: a native <fieldset> and <legend>, drawn as a Field is.
// The error is tied to the group with aria-describedby, and shown only while it applies.
export default function FieldGroup() {
const [picked, setPicked] = useState<string[]>([]);
const [tried, setTried] = useState(false);
const invalid = tried && picked.length === 0;
return (
<form
style={{ display: 'grid', gap: 'var(--space-sm)', justifyItems: 'start' }}
onSubmit={(event) => {
event.preventDefault();
setTried(true);
}}
>
<fieldset
className="fieldset"
aria-describedby={invalid ? 'field-group-hint field-group-error' : 'field-group-hint'}
>
<legend className="fieldset-legend">Notify me by</legend>
<p id="field-group-hint" className="fieldset-description">
Pick one or more.
</p>
{options.map((option) => (
<label key={option} htmlFor={`field-group-${option}`} style={row}>
<Checkbox.Root
id={`field-group-${option}`}
checked={picked.includes(option)}
onCheckedChange={(checked) =>
setPicked((list) =>
checked === true ? [...list, option] : list.filter((o) => o !== option),
)
}
>
<Checkbox.Indicator />
</Checkbox.Root>
{option}
</label>
))}
{invalid && (
<p id="field-group-error" className="fieldset-error">
Choose at least one.
</p>
)}
</fieldset>
<Button type="submit" variant="primary">
Save
</Button>
</form>
);
}

Tie the description and the error to the group yourself, with aria-describedby on the fieldset, naming only what is shown. A RadioGroup can be the fieldset itself: <RadioGroup.Root render={<fieldset />} className="fieldset">.

Field.Root props
PropTypeDefaultDescription
invalidbooleanfalseShows the Error and marks the control aria-invalid. Yours to set: Field validates nothing.
disabledbooleanfalseDisables the control.
requiredbooleanfalseMarks the control required, so the browser's own validation reports it.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Field.Control props
PropTypeDefaultDescription
renderReactElement<Input />The control: <Select />, <Textarea />, <Checkbox.Root />, <Combobox.Input /> or your own. Gets the id, aria-describedby, aria-invalid, disabled and required.

Every other prop goes to the control. Field.Label, Field.Description and Field.Error take the props of the element they render, plus render and ref.