zeroUI

Form

The frame around Fields: Base UI's form, which validates every Field on submit and shows errors handed back from the server, with fieldsets under leaning legends.

Install

terminal
# once per project: where @zeroui items come frompnpm dlx shadcn@latest registry add @zeroui=https://zeroui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zeroui/form

Examples

Post a job, with an error from the server

Post a job

In credits, paid on hand-in.

form-job.tsx
import { useState } from "react";import { Button } from "@/components/zero/button";import {  Field,  FieldDescription,  FieldError,  FieldLabel,} from "@/components/zero/field";import {  Fieldset,  FieldsetLegend,  Form,  FormActions,} from "@/components/zero/form";import { Input } from "@/components/zero/input";import { Textarea } from "@/components/zero/textarea";export default function FormJob() {  const [errors, setErrors] = useState<Record<string, string>>({});  const [posted, setPosted] = useState("");  return (    <Form      className="w-full max-w-md"      errors={errors}      onFormSubmit={(values) => {        // Stands in for the server: it rejects a reward under 500.        if (Number(values.reward) < 500) {          setErrors({ reward: "The dispatcher posts nothing under 500 cr." });          return;        }        setErrors({});        setPosted(String(values.title));      }}    >      <Fieldset>        <FieldsetLegend>Post a job</FieldsetLegend>        <Field name="title">          <FieldLabel>Title</FieldLabel>          <Input required placeholder="Sealed crate" />          <FieldError>Give the job a title.</FieldError>        </Field>        <Field          name="reward"          validate={(value) => (value ? null : "Name the reward.")}        >          <FieldLabel>Reward</FieldLabel>          <Input type="number" inputMode="numeric" placeholder="3100" />          <FieldDescription>In credits, paid on hand-in.</FieldDescription>          {/* its own message, or the server's, from the form's errors */}          <FieldError />        </Field>        <Field name="notes">          <FieldLabel>Notes</FieldLabel>          <Textarea placeholder="Anything the driver should know" />        </Field>      </Fieldset>      <FormActions>        <Button type="submit">Post it</Button>        {posted ? (          <span className="font-medium font-zero-text text-zero-sm text-zero-accent">            Posted: {posted}          </span>        ) : null}      </FormActions>    </Form>  );}

With react-hook-form and zod

form-hook.tsx
import { zodResolver } from "@hookform/resolvers/zod";import { Controller, useForm } from "react-hook-form";import { z } from "zod";import { Button } from "@/components/zero/button";import { Field, FieldError, FieldLabel } from "@/components/zero/field";import { Form, FormActions } from "@/components/zero/form";import { Input } from "@/components/zero/input";const schema = z.object({  driver: z.string().min(2, "At least two letters."),  code: z    .string()    .regex(/^[A-Z0-9]{4}$/, "Four letters or digits, in capitals."),});type Values = z.infer<typeof schema>;export default function FormHook() {  const form = useForm<Values>({    resolver: zodResolver(schema),    defaultValues: { driver: "", code: "" },  });  return (    <Form      className="w-full max-w-md"      onSubmit={form.handleSubmit(() => form.reset())}    >      {(["driver", "code"] as const).map((name) => (        <Controller          key={name}          name={name}          control={form.control}          render={({ field, fieldState }) => (            <Field name={name} invalid={fieldState.invalid}>              <FieldLabel>                {name === "driver" ? "Driver" : "Gate code"}              </FieldLabel>              <Input {...field} />              <FieldError match={fieldState.invalid}>                {fieldState.error?.message}              </FieldError>            </Field>          )}        />      ))}      <FormActions>        <Button type="submit">Sign for it</Button>      </FormActions>    </Form>  );}

Behaviour

Form collects every Field's value and runs their validation on submit; errors, keyed by each Field's name, puts a server's messages in the Fields' FieldError. Fieldset groups Fields under a legend led by a slanted sliver; disabling it disables everything inside. FormActions is the row of buttons at the end.

With react-hook-form, wire each control through its Controller and give the Field invalid and the FieldError match from the field's state, as in the second example.

Props

Form

PropTypeDefaultDescription
errorsRecord<string, string | string[]>—Messages from the server, by field name.
onFormSubmit(values) => void—Called with every Field's value once they are all valid.

Also takes every prop of Base UI's Form.

Fieldset, FieldsetLegend

PropTypeDefaultDescription
disabledbooleanfalseDisables every control inside.

Also takes every prop of Base UI's Fieldset.Root.

FormActions