// preview in
saved for every page

Forms

Build forms that tell people what went wrong and where. Field wiring, validation, an error summary and the react-hook-form recipe.

// you'll learn

  • What Field connects for you, and what is left to your markup
  • Two ways to validate: on the field, or with your own state
  • When to add an ErrorSummary, and where focus goes after a failed submit
  • How to plug in react-hook-form and zod without losing any of it

Wire a field

Field ties a label, a description and an error to the control inside it. FieldLabel becomes a <label> for the control, and FieldDescription and FieldError are linked with aria-describedby, so a screen reader reads the hint and the error when the control gets focus.

signup.tsx
import { Field, FieldDescription, FieldError, FieldLabel, Input } from "@camaleo/react";

<Field validationMode="onBlur">
  <FieldLabel>Work email</FieldLabel>
  <Input type="email" autoComplete="email" required />
  <FieldDescription>We reply within one business day.</FieldDescription>
  <FieldError match="valueMissing">Enter your work email.</FieldError>
</Field>

Input, Textarea, NativeSelect, Select, Combobox, NumberField, Checkbox, Switch, Slider and the date fields all join the Field they sit in. Group related fields with Fieldset and FieldsetLegend. For a checkbox or switch on its own, a plain Label around it is enough.

Validate on the field

Field runs the browser's constraint checks (required, type="email", minLength…) and your own validate function. FieldError shows the message; match picks which failure it reports, and a validate result is shown as is.

validationMode sets when it checks. Base UI's default, "onSubmit", is meant for its own Form component, which Camaleo doesn't wrap, so set "onBlur" or "onChange" on the Field, or let a form library decide (below).

minimal · light · #3fa34d

Lowercase letters, numbers, dots and underscores.

username.tsx
<Field
  validationMode="onChange"
  validate={(value) => (String(value ?? "").length < 3 ? "Use at least 3 characters." : null)}
>
  <FieldLabel>Username</FieldLabel>
  <Input autoComplete="username" />
  <FieldError />
</Field>

When the result comes from somewhere else (a server, a form library), set invalid on the Field and pass the same flag to match on FieldError, which then shows its children as they are. That is how the recipe below works.

TIP

Write errors that say how to fix the problem: "Enter an email like ana@company.com", not "Invalid input". Each component page has a do/don't pair about it.

Summarize the errors

When a form has more than a few fields, a failed submit should land on an ErrorSummary at the top: a danger alert whose title says how many fields need fixing, with one link per error. Each link moves focus to its control.

checkout.tsx
const summary = useRef<HTMLDivElement>(null);

// After a failed submit, focus the summary so it is read first.
useEffect(() => {
  if (failedAttempts > 0) summary.current?.focus();
}, [failedAttempts]);

{errors.length > 0 && (
  <ErrorSummary
    ref={summary}
    tabIndex={-1}
    title={`Fix ${errors.length} fields to continue.`}
    errors={errors.map((e) => ({ target: e.id, message: e.message }))}
  />
)}

target is the id of the control. Give each control an id from useId(), so two copies of the form on one page never collide.

Use react-hook-form and zod

react-hook-form keeps the values and runs the schema; Camaleo shows the result. Wrap each control in a Controller, pass fieldState.invalid to the Field, and spread field on the control:

request-demo.tsx
const { control, handleSubmit } = useForm<Values>({
  resolver: zodResolver(schema),
  defaultValues: { email: "" },
  // The ErrorSummary takes focus, not the first invalid field.
  shouldFocusError: false,
});

<Controller
  name="email"
  control={control}
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldLabel>Work email</FieldLabel>
      <Input {...field} id={ids.email} type="email" autoComplete="email" />
      <FieldError match={fieldState.invalid}>{fieldState.error?.message}</FieldError>
    </Field>
  )}
/>
CAREFUL

Set shouldFocusError: false when the form has an ErrorSummary. Otherwise react-hook-form moves focus to the first invalid field first, and people who use a screen reader never hear the summary.

A Checkbox reports checked instead of value, so connect it by hand: checked={field.value}, onCheckedChange={field.onChange} and ref={field.ref}.

A complete form

Everything above in one form: five fields, a schema, the summary with focus, and a confirmation that takes focus when the form is gone. Send it empty, then fix the fields one by one. Open the Code tab for the whole file.

minimal · light · #3fa34d

We reply within one business day.

At least 20 characters.

Before you ship it

  • Every control has a visible label, not just a placeholder
  • Each error says how to fix it, in the field and in the summary
  • After a failed submit, focus lands on the summary; each link reaches its field
  • noValidate on the <form>, so browser bubbles don't compete with your messages
  • Ids come from useId(), and autoComplete is set on personal data
Was this page helpful? · Edit this page on GitHub