Experience System

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-system

TanStack Form and Zod are not bundled with @by/experience-system. Install them in the application that owns the form:

pnpm add @tanstack/react-form zod

In 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:

TopicExperience system article
Field, FieldGroup, FieldLabel, FieldError, …Field
InputInput
TextareaTextarea
SelectSelect
CheckboxCheckbox
RadioGroup, RadioRadio group
SwitchSwitch
Card, ButtonCard, Button
toast, SonnerSonner

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.