Building forms with Toro UI's field system and validation.
Toro UI provides a composable field system for building forms. Pair it with Zod for schema validation and you get type-safe forms with accessible error handling.
The field system is made up of composable parts:
| Component | Purpose |
|---|---|
FieldSet | Groups related fields, renders <fieldset> |
FieldLegend | Title for a field set, renders <legend> |
FieldGroup | Layout container for a group of fields |
Field | Wraps a single form control with its label and messages |
FieldLabel | Accessible label for a form control |
FieldContent | Wrapper for description and error below a control |
FieldDescription | Help text for a field |
FieldError | Validation error message |
FieldSeparator | Visual divider between fields |
import { Input } from "@/components/ui/input"
import {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldSet,
FieldLegend,
} from "@/components/ui/field"
<form>
<FieldSet>
<FieldLegend>Contact Information</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel>Name</FieldLabel>
<Input placeholder="Your name" />
</Field>
<Field>
<FieldLabel>Email</FieldLabel>
<Input type="email" placeholder="you@example.com" />
<FieldDescription>We'll never share your email.</FieldDescription>
</Field>
</FieldGroup>
</FieldSet>
</form>Fields support three orientation modes for label placement:
// Label above the input (default)
<Field orientation="vertical">
<FieldLabel>Name</FieldLabel>
<Input />
</Field>
// Label beside the input
<Field orientation="horizontal">
<FieldLabel>Name</FieldLabel>
<Input />
</Field>
// Vertical on mobile, horizontal on wider screens
<Field orientation="responsive">
<FieldLabel>Name</FieldLabel>
<Input />
</Field>Use Zod schemas for type-safe validation. The FieldError component can display a list of error messages:
import { z } from "zod"
const schema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
function LoginForm() {
const [errors, setErrors] = useState({})
function onSubmit(e) {
e.preventDefault()
const data = Object.fromEntries(new FormData(e.target))
const result = schema.safeParse(data)
if (!result.success) {
setErrors(result.error.flatten().fieldErrors)
return
}
// Handle valid data
}
return (
<form onSubmit={onSubmit}>
<FieldGroup>
<Field data-invalid={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<Input name="email" type="email" aria-invalid={!!errors.email} />
<FieldError errors={errors.email?.map(m => ({ message: m }))} />
</Field>
<Field data-invalid={!!errors.password}>
<FieldLabel>Password</FieldLabel>
<Input name="password" type="password" aria-invalid={!!errors.password} />
<FieldError errors={errors.password?.map(m => ({ message: m }))} />
</Field>
</FieldGroup>
</form>
)
}FieldError handles single and multiple errors automatically:
// Single error — renders as text
<FieldError errors={[{ message: "Required" }]} />
// Multiple errors — renders as a list
<FieldError errors={[
{ message: "Must be at least 8 characters" },
{ message: "Must contain a number" },
]} />
// Or pass children directly
<FieldError>This field is required</FieldError>Use Field with checkbox and radio components for consistent layout:
<FieldSet>
<FieldLegend variant="label">Notifications</FieldLegend>
<Field orientation="horizontal">
<Checkbox id="email-notifications" />
<FieldLabel htmlFor="email-notifications">
Email notifications
</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="sms-notifications" />
<FieldLabel htmlFor="sms-notifications">
SMS notifications
</FieldLabel>
</Field>
</FieldSet>Use FieldSeparator to visually divide groups of fields, optionally with a label:
<FieldGroup>
<Field>
<FieldLabel>Email</FieldLabel>
<Input type="email" />
</Field>
<FieldSeparator>or</FieldSeparator>
<Field>
<FieldLabel>Phone</FieldLabel>
<Input type="tel" />
</Field>
</FieldGroup>