Field
Wires labels, descriptions and errors to form controls with the right ARIA attributes.
We'll never share your email with anyone else.
import { Field } from '@/components/field';
import { Input } from '@/components/input';
export default function FieldPreview() {
return (
<Field className="w-72">
<Field.Label>Email</Field.Label>
<Field.Control>
<Input type="email" placeholder="name@example.com" />
</Field.Control>
<Field.Description>
We'll never share your email with anyone else.
</Field.Description>
</Field>
);
} Dependencies
Source Code
import {
createContext,
use,
useCallback,
useEffect,
useId,
useMemo,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';
interface FieldContextValue {
controlId: string;
labelId: string;
describedBy: string | undefined;
invalid: boolean | undefined;
registerMessage: (id: string) => () => void;
}
const FieldContext = createContext<FieldContextValue | null>(null);
const useFieldContext = () => {
const ctx = use(FieldContext);
if (!ctx) {
throw new Error('Field components must be used within a <Field />');
}
return ctx;
};
interface FieldProps extends React.ComponentPropsWithRef<'div'> {
/**
* Marks the wrapped control as invalid. Wire this to your validation source
* (RHF, Zod, server response, plain useState — Field doesn't care).
*/
invalid?: boolean;
}
const Field = ({ invalid, className, children, ...props }: FieldProps) => {
const controlId = useId();
const labelId = useId();
const [messageIds, setMessageIds] = useState<string[]>([]);
const registerMessage = useCallback((id: string) => {
setMessageIds((prev) => [...prev, id]);
return () => {
setMessageIds((prev) => prev.filter((existing) => existing !== id));
};
}, []);
const describedBy = messageIds.length > 0 ? messageIds.join(' ') : undefined;
const ctx = useMemo<FieldContextValue>(
() => ({ controlId, labelId, describedBy, invalid, registerMessage }),
[controlId, labelId, describedBy, invalid, registerMessage]
);
return (
<FieldContext value={ctx}>
<div
className={cn('flex flex-col gap-1.5', className)}
data-invalid={invalid || undefined}
{...props}
>
{children}
</div>
</FieldContext>
);
};
const FieldLabel = ({
className,
...props
}: React.ComponentPropsWithRef<'label'>) => {
const { controlId, labelId } = useFieldContext();
return (
<label
htmlFor={controlId}
id={labelId}
className={cn('font-medium text-base text-foreground', className)}
{...props}
/>
);
};
interface FieldControlProps {
children: React.ReactNode;
}
const FieldControl = ({ children }: FieldControlProps) => {
const { controlId, labelId, describedBy, invalid } = useFieldContext();
return (
<Slot
id={controlId}
aria-labelledby={labelId}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
>
{children}
</Slot>
);
};
const FieldDescription = ({
className,
children,
...props
}: React.ComponentPropsWithRef<'p'>) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const { registerMessage } = useFieldContext();
const hasContent = !!children;
useEffect(() => {
if (!hasContent) return;
return registerMessage(id);
}, [id, hasContent, registerMessage]);
if (!hasContent) return null;
return (
<p
id={id}
className={cn('text-foreground-secondary text-sm', className)}
{...props}
>
{children}
</p>
);
};
const FieldError = ({
className,
children,
...props
}: React.ComponentPropsWithRef<'p'>) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const { registerMessage } = useFieldContext();
const hasContent = !!children;
useEffect(() => {
if (!hasContent) return;
return registerMessage(id);
}, [id, hasContent, registerMessage]);
if (!hasContent) return null;
return (
<p
id={id}
role="alert"
className={cn('text-error text-sm', className)}
{...props}
>
{children}
</p>
);
};
const CompoundField = Object.assign(Field, {
Label: FieldLabel,
Control: FieldControl,
Description: FieldDescription,
Error: FieldError,
});
export { CompoundField as Field }; Overview
Field is the connective tissue between a label, a control, a description and an error. It wires the right id / aria-labelledby / aria-describedby / aria-invalid attributes onto the control without you having to keep track of ids by hand, and without forking every form primitive to consume a context.
The base form components (Input, Textarea, Checkbox, Radio, Select, Switch) stay dumb on purpose — they don’t know about Field. When you want accessible wiring, you reach for Field. When you don’t, native form controls work as-is.
Anatomy
<Field>
<Field.Label />
<Field.Control>
<input />
</Field.Control>
<Field.Description />
<Field.Error />
</Field>
Field.Control is a Slot that injects ARIA attrs into its single child. The child can be any form control that spreads its props — Foundations primitives like Input, Textarea, Checkbox, Radio, Select and Switch all work directly.
API Reference
Field
The root component. Provides context for the rest, generates ids, and lays its children out as a flex column with a small gap so you don’t have to add spacing by hand.
| Prop | Default | Type | Description |
|---|---|---|---|
invalid | - | boolean | Marks the wrapped control as invalid. `aria-invalid` is set on the control and `data-invalid` on the root for styling. Wire this to your validation source — RHF, Zod, server response, plain useState. Field doesn't care. |
className | - | string | Forwarded to the root `<div>`. |
Field.Label
A <label> with htmlFor and id taken from context.
Field.Control
Wraps its single child (a form control) and injects id, aria-labelledby, aria-describedby and aria-invalid.
Field.Description
Renders a <p> and registers its id into the field’s aria-describedby array. You can render multiple descriptions — they’ll all be linked.
Field.Error
Same as Field.Description, but only renders (and only registers) when its children are truthy. Adds role="alert" so screen readers announce changes. Pair with invalid on the root for the full picture.
ARIA reasoning
Every relationship is expressed via attributes a screen reader actually consumes:
<label htmlFor>andaria-labelledbyboth point to the label, giving screen readers a name for the control on focus.aria-describedbycarries a space-separated list of message ids — both descriptions and errors. When an error appears or disappears, its id is added to or removed from the list automatically.aria-invalidflips on wheninvalid={true}.
We use aria-describedby for errors instead of aria-errormessage deliberately. aria-errormessage has weaker screen-reader support (especially older versions of NVDA and JAWS) and the mental model is simpler with one list of messages — descriptions and errors compose the same way.
Examples
Basic
A label, an input and a description.
We'll never share your email with anyone else.
import { Field } from '@/components/field';
import { Input } from '@/components/input';
export default function FieldPreview() {
return (
<Field className="w-72">
<Field.Label>Email</Field.Label>
<Field.Control>
<Input type="email" placeholder="name@example.com" />
</Field.Control>
<Field.Description>
We'll never share your email with anyone else.
</Field.Description>
</Field>
);
} With error
The error appears (and registers itself in aria-describedby) only while validation fails.
Please enter a valid email address.
import { useState } from 'react';
import { Field } from '@/components/field';
import { Input } from '@/components/input';
export default function FieldErrorPreview() {
const [value, setValue] = useState('not-an-email');
const error =
value.length > 0 && !value.includes('@')
? 'Please enter a valid email address.'
: '';
return (
<Field invalid={!!error} className="w-72">
<Field.Label>Email</Field.Label>
<Field.Control>
<Input
type="email"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="name@example.com"
/>
</Field.Control>
<Field.Error>{error}</Field.Error>
</Field>
);
} Radio group
Native <fieldset> and <legend> group the radios; one Field per option carries the per-option label and description. The radio’s name attribute keeps them mutually exclusive.
import { useState } from 'react';
import { Field } from '@/components/field';
import { Radio } from '@/components/radio';
const plans = [
{ value: 'free', label: 'Free', description: 'For personal projects.' },
{ value: 'pro', label: 'Pro', description: '$12/mo. For small teams.' },
{
value: 'enterprise',
label: 'Enterprise',
description: 'Custom pricing. SSO and audit logs.',
},
];
export default function FieldRadioGroupPreview() {
const [plan, setPlan] = useState('free');
return (
<fieldset className="flex w-72 flex-col gap-3">
<legend className="font-medium text-base">Plan</legend>
{plans.map((p) => (
<Field key={p.value} className="flex-row items-center gap-3">
<Field.Control>
<Radio
name="plan"
value={p.value}
checked={plan === p.value}
onChange={() => setPlan(p.value)}
/>
</Field.Control>
<div className="flex flex-col">
<Field.Label>{p.label}</Field.Label>
<Field.Description>{p.description}</Field.Description>
</div>
</Field>
))}
</fieldset>
);
} With React Hook Form
Pass !!errors.x to invalid and errors.x?.message as Field.Error children. That’s the entire integration. The same pattern works with TanStack Form, Formik, or your own useState-driven validation.
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/button';
import { Field } from '@/components/field';
import { Input } from '@/components/input';
const schema = z.object({
email: z.string().email('Please enter a valid email address.'),
password: z.string().min(8, 'Password must be at least 8 characters long.'),
});
type FormValues = z.infer<typeof schema>;
export default function FieldWithRhfPreview() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
mode: 'onTouched',
});
const onSubmit = handleSubmit(async (values) => {
await new Promise((resolve) => setTimeout(resolve, 600));
console.log('submitted', values);
});
return (
<form onSubmit={onSubmit} className="flex w-80 flex-col gap-4">
<Field invalid={!!errors.email}>
<Field.Label>Email</Field.Label>
<Field.Control>
<Input type="email" {...register('email')} />
</Field.Control>
<Field.Error>{errors.email?.message}</Field.Error>
</Field>
<Field invalid={!!errors.password}>
<Field.Label>Password</Field.Label>
<Field.Control>
<Input type="password" {...register('password')} />
</Field.Control>
<Field.Description>At least 8 characters.</Field.Description>
<Field.Error>{errors.password?.message}</Field.Error>
</Field>
<Button type="submit" isLoading={isSubmitting}>
Sign up
</Button>
</form>
);
} Caveats
Components that aren’t a single form control
Field.Control injects props onto a single child. That works for native inputs and Foundations primitives that wrap them. It doesn’t work cleanly for compound primitives where the actual control isn’t the root element:
- Slider — the form control is
Slider.Thumb(a native<input type="range">). Wrap that, not the root. - OTP Input — the root is a container; the inputs are the cells.
- Listbox — its trigger is a
<button>whose attributes are managed by floating-ui, not the rendered DOM at the top of the tree.
For these, either keep them outside Field or wire ARIA manually with the ids you control.
Custom selects
If you build a custom select on top of a <button> (rather than the native <select>), avoid aria-invalid and treating it as a form control for validation purposes — those attributes are reserved for elements that have a native validity state. Use the native <select> (which Foundations exports) when you need form-validation semantics.
Don’t override id / aria-* on Field.Control’s child
Field.Control uses Slot, which lets the child’s explicit props override the injected ones. If you write <Field.Control><Input id="custom" /></Field.Control>, your id wins — and Field.Label’s htmlFor will no longer match. That’s the explicit override path; reserve it for the rare case where you genuinely need a fixed id.
Previous
Drawer
Next
File Upload