Form
Combine TanStack Form and Zod with Field, inputs, and related primitives from the experience system.
Installation
Primitives such as Field, Input, and Card come from @by/experience-system. Add the package with your package manager:
pnpm add @by/experience-systemTanStack Form and Zod are not bundled with @by/experience-system. Install them in the application that owns the form:
pnpm add @tanstack/react-form zodIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system.
Composition
Use TanStack Form for state and validation, and Experience System Field parts for layout, labels, and errors:
useForm (TanStack Form)
└── form
└── HTML form (onSubmit → preventDefault, handleSubmit)
└── form.Field (per value or mode="array")
└── Field (experience system)
├── FieldLabel
├── (control: Input, Textarea, Select, Checkbox, …)
├── FieldDescription (optional)
└── FieldError (optional)form.Field uses a render function so each control reads field.state, field.handleChange, and field.handleBlur. Pair FieldError with field.state.meta.errors after submit or touch, following TanStack Form and the shadcn/ui TanStack Form guide.
Usage
Add 'use client' in the Next.js App Router when you use hooks. Wire validators (for example onSubmit) to a Zod schema, set data-invalid on Field and aria-invalid on the control when field.state.meta.isTouched && !field.state.meta.isValid, and use noValidate on the <form> if you rely on schema messages instead of native browser validation.
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from '@by/experience-system';
import { useForm } from '@tanstack/react-form';
import * as z from 'zod';
const schema = z.object({
name: z.string().min(1, 'Required.'),
});
export function Example() {
const form = useForm({
defaultValues: { name: '' },
validators: { onSubmit: schema },
onSubmit: async ({ value }) => {
console.log(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
noValidate
>
<FieldGroup>
<form.Field name="name">
{(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
/>
{isInvalid ? <FieldError errors={field.state.meta.errors} /> : null}
</Field>
);
}}
</form.Field>
</FieldGroup>
<Button type="submit">Submit</Button>
</form>
);
}Mount Sonner once (for example in the root layout) if you use toast from @by/experience-system for submit feedback, or follow the isolated preview pattern in the Sonner article.
Examples
Input
Username with length and pattern rules.
Textarea
This example uses variant="inset" (the default recessed surface). When Textarea sits next to Input, Select, combobox, or InputGroup, use variant="flat" for surface parity—see Textarea — Surface variant.
Longer text with a minimum length.
Select
Controlled Select with SelectTrigger, SelectValue, and SelectContent.
Checkbox
form.Field with mode="array" and checkboxes that push or remove values.
Radio group
RadioGroup with Radio options and descriptions.
Switch
Horizontal Field with a boolean Switch and validation.
API Reference
This page is a cookbook, not a new export from @by/experience-system. Behavior and types for useForm, form.Field, validators, and field state come from TanStack Form. Schema validation in the examples uses Zod.
Layout, validation affordances (data-invalid), FieldError (errors prop), and controls are the same components documented elsewhere on this site. Use the API Reference sections on those pages for props, data attributes, and accessibility hooks:
| Topic | Experience system article |
|---|---|
Field, FieldGroup, FieldLabel, FieldError, … | Field |
Input | Input |
Textarea | Textarea |
Select | Select |
Checkbox | Checkbox |
RadioGroup, Radio | Radio group |
Switch | Switch |
Card, Button | Card, Button |
toast, Sonner | Sonner |
Accessibility
Set aria-invalid on the focused control when the field is touched and invalid, keep FieldLabel associated via htmlFor / id, and render FieldError with field.state.meta.errors so assistive technologies receive role="alert" messages from the Experience System FieldError. TanStack Form manages focus and submission flow; see TanStack Form — React and the Field accessibility section for baseline patterns.
Source in the repo for Field: packages/experience-system/src/components/Field/Field.tsx. Agent-oriented contracts: packages/experience-system/src/components/Field/Field.instructions.md.