Skip to content
Monogem

Component · Composite & Advanced

Form

Overview

A form is a composition pattern, not a component. Monogem ships no <Form> primitive: a form is built by placing the existing controls — Label, Input, Textarea, Checkbox, Radio Group, Switch, Select, Button — into markup that wires them together with native HTML (<form>, <fieldset>, <legend>) and a small amount of ARIA. This page is the canonical guidance for that wiring: how fields stack, how they group, how required/optional and validation are communicated, how a whole form reports success and failure, and how it behaves while submitting.

This is deliberately not a form library. There is no schema system, no validation framework, no useForm / useField hook, and no controlled-form state model in Monogem. Validation guidance here (what to show, where, and how to announce it) is a documentation contract; the mechanism that runs it — native constraint validation, a resolver library, a server round-trip — is the application's choice and is out of scope.

Anatomy

<form>                                  aria-busy="true" while submitting
  ├─ Form-level feedback   <Alert>      error summary (role="alert") or success (role="status"),
  │                                     rendered at the top of the form
  ├─ <fieldset disabled={submitting}>   the form body — one disabled cascade covers every control
  │   ├─ Field                          Label → control → message (the shared form-field model)
  │   │   ├─ <Label htmlFor>            names the control; "(optional)" suffix when not required
  │   │   ├─ Control                    <Input> / <Textarea> / <Select> / … — aria-describedby +
  │   │   │                             aria-invalid set by the composing markup
  │   │   └─ Message                    helper text (default) OR error text (after validation)
  │   ├─ Field group   <fieldset><legend>   related controls (a Radio Group, a set of Checkboxes)
  │   │   └─ Field group message        helper / error associated to the <fieldset>
  │   └─ Action row     <div>           submit + secondary actions; primary first in DOM order
  └─ (success replaces the form, or the form resets — the caller's choice)
  • The field is the atom, and it is already defined — see Input → "The shared form-field model". Form does not redefine it. The stack is Label → --scale-2 (8px) → control → --scale-1-5 (6px) → message; stacked fields in a form are separated by --scale-6 (24px), the step Spacing names "gap between form fields".
  • A field group is a native <fieldset> with a visible <legend>. Use it whenever several controls answer one question — a Radio Group already renders its own <fieldset> / <legend>; a set of related Checkboxes needs one added by hand. The <legend> uses the text-sm font-medium leading-snug text-foreground recipe (the same one Radio Group's legend uses). A group-level helper or error is a normal message, associated to the <fieldset> through aria-describedby (and aria-invalid on the <fieldset> when the group is invalid).
  • Form-level feedback is an Alert, used unchanged, at the top of the form — an error summary after a failed submit, or a success confirmation. It is not a new component.
  • The action row is a <div> of Buttons. No <FormActions> component; it is layout in the markup.

Live Example

One field — label → control → message, with “(optional)” on fields that aren’t required

As it appears on your ID.

A sentence or two is plenty.

Field-level validation — an invalid field pairs the “--destructive” border with an icon and error text (never colour alone); the error replaces the helper text and “aria-describedby” follows it

We’ll only use this to send a receipt.

Grouped controls — a native “fieldset” / “legend” names the set; a group-level message is associated to the fieldset

Plan
How can we contact you?

Pick every channel that works for you.

Monthly. Unsubscribe anytime.

A Select composes like any other field — label, control, helper text, the same rhythm

Sets your billing currency.

A whole form — a top-of-form summary on a failed submit (focus moves to it), a disabled fieldset + busy form + loading button while submitting, and a success confirmation when it completes

Account details

Use your company address.

At least 8 characters.

Action layout — a destructive form action sits apart from the confirm / cancel pair

Code Example

form-showcase.tsx
"use client";

import * as React from "react";
import { TriangleAlert } from "lucide-react";

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";

/**
* Live Form example — Form is a composition PATTERN, not a component. There is
* no `<Form>` import and no `components/ui/form.tsx`: every section below is
* plain markup wiring the existing Monogem primitives (Label, Input, Textarea,
* Checkbox, Radio Group, Switch, Select, Button) and Alert together by hand.
*
* `"use client"` because the validation demo and the submit lifecycle hold
* state. The `id` / `aria-describedby` / `aria-invalid` wiring is written out in
* full on every control on purpose — that wiring is the thing this page teaches.
*/

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
{children}
</h3>
);
}

/**
* Field-level error line. Never colour alone: a `TriangleAlert` glyph plus text
* in `--foreground`, sitting under the control. `id` is what the control's
* `aria-describedby` points at while the field is invalid.
*/
function FieldError({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
return (
<p
id={id}
className="flex items-start gap-1 text-xs leading-snug text-foreground"
>
<TriangleAlert
aria-hidden="true"
className="mt-px size-3.5 shrink-0 [stroke-width:var(--icon-stroke-width)]"
/>
<span>{children}</span>
</p>
);
}

/** Standing helper text — the Caption recipe in `--muted-foreground`. */
function FieldHelp({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
return (
<p id={id} className="text-xs leading-snug text-muted-foreground">
{children}
</p>
);
}

/* -------------------------------------------------------------------------- */

type Status = "idle" | "submitting" | "success" | "error";

const SIGNUP_LABELS: Record<string, string> = {
"signup-email": "Work email",
"signup-password": "Password",
"signup-terms": "Terms of service",
};

function validateSignup(values: Record<string, string>): Record<string, string> {
const errors: Record<string, string> = {};
if (!EMAIL_RE.test(values["signup-email"] ?? "")) {
errors["signup-email"] = "Enter a valid work email address.";
}
if ((values["signup-password"] ?? "").length < 8) {
errors["signup-password"] = "Use at least 8 characters.";
}
// The form opts out of native constraint validation (`noValidate`), so a
// `required` control is only enforced if the custom validator checks it.
if (values["signup-terms"] !== "checked") {
errors["signup-terms"] = "Accept the terms of service to continue.";
}
return errors;
}

export function FormShowcase() {
// Section 2 — a single field that validates on blur once it has been touched.
const [email, setEmail] = React.useState("");
const [emailTouched, setEmailTouched] = React.useState(false);
const emailError =
emailTouched && !EMAIL_RE.test(email)
? "Enter an email address like name@example.com."
: null;

// Section 3 — a checkbox group that validates "at least one" after the user
// has interacted with it. The invalid state lives on the <fieldset>.
const [contacts, setContacts] = React.useState<Set<string>>(() => new Set());
const [contactsTouched, setContactsTouched] = React.useState(false);
const contactError =
contactsTouched && contacts.size === 0
? "Choose at least one contact method."
: null;
const toggleContact = (value: string) => {
setContactsTouched(true);
setContacts((prev) => {
const next = new Set(prev);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
return next;
});
};

// Section 5 — the whole-form submit lifecycle.
const [status, setStatus] = React.useState<Status>("idle");
const [attempted, setAttempted] = React.useState(false);
const [values, setValues] = React.useState<Record<string, string>>({
"signup-email": "",
"signup-password": "",
"signup-terms": "",
});
const summaryRef = React.useRef<HTMLDivElement>(null);

const setValue = (id: string, value: string) =>
setValues((prev) => ({ ...prev, [id]: value }));

const errors =
attempted && status !== "success" ? validateSignup(values) : {};
const errorList = Object.entries(errors);

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setAttempted(true);
const found = validateSignup(values);
if (Object.keys(found).length > 0) {
setStatus("error");
// Move focus to the summary so a keyboard / screen-reader user lands on
// the list of problems instead of staying on the submit button.
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
setStatus("submitting");
window.setTimeout(() => setStatus("success"), 1200);
}

return (
<div className="flex flex-col gap-10">
{/* 1 — one field --------------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
One field — label → control → message, with “(optional)” on fields that
aren’t required
</SectionHeading>
<div className="flex max-w-sm flex-col gap-6">
<div>
<Label htmlFor="demo-name">Full name</Label>
<div className="mt-2">
<Input
id="demo-name"
name="name"
required
autoComplete="name"
aria-describedby="demo-name-help"
/>
</div>
<div className="mt-1.5">
<FieldHelp id="demo-name-help">
As it appears on your ID.
</FieldHelp>
</div>
</div>

<div>
<Label htmlFor="demo-company" optional>
Company
</Label>
<div className="mt-2">
<Input
id="demo-company"
name="company"
autoComplete="organization"
/>
</div>
</div>

<div>
<Label htmlFor="demo-about" optional>
About you
</Label>
<div className="mt-2">
<Textarea
id="demo-about"
name="about"
aria-describedby="demo-about-help"
/>
</div>
<div className="mt-1.5">
<FieldHelp id="demo-about-help">
A sentence or two is plenty.
</FieldHelp>
</div>
</div>
</div>
</section>

{/* 2 — field-level validation ----------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
Field-level validation — an invalid field pairs the “--destructive”
border with an icon and error text (never colour alone); the error
replaces the helper text and “aria-describedby” follows it
</SectionHeading>
<div className="max-w-sm">
<Label htmlFor="demo-email">Email address</Label>
<div className="mt-2">
<Input
id="demo-email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setEmailTouched(true)}
aria-invalid={emailError ? "true" : undefined}
aria-describedby={
emailError ? "demo-email-error" : "demo-email-help"
}
/>
</div>
<div className="mt-1.5">
{emailError ? (
<FieldError id="demo-email-error">{emailError}</FieldError>
) : (
<FieldHelp id="demo-email-help">
We’ll only use this to send a receipt.
</FieldHelp>
)}
</div>
</div>
</section>

{/* 3 — grouped controls --------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
Grouped controls — a native “fieldset” / “legend” names the set; a
group-level message is associated to the fieldset
</SectionHeading>
<div className="flex max-w-sm flex-col gap-6">
<RadioGroup label="Plan" name="demo-plan">
<div className="flex items-center gap-2">
<RadioGroupItem id="demo-plan-solo" value="solo" />
<Label htmlFor="demo-plan-solo">Solo</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="demo-plan-team" value="team" defaultChecked />
<Label htmlFor="demo-plan-team">Team</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="demo-plan-enterprise" value="enterprise" />
<Label htmlFor="demo-plan-enterprise">Enterprise</Label>
</div>
</RadioGroup>

<fieldset
className="min-w-0 border-0 p-0"
aria-invalid={contactError ? "true" : undefined}
aria-describedby={
contactError ? "demo-contact-error" : "demo-contact-help"
}
>
<legend className="text-sm font-medium leading-snug text-foreground">
How can we contact you?
</legend>
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="demo-contact-email"
name="contact"
value="email"
checked={contacts.has("email")}
onChange={() => toggleContact("email")}
/>
<Label htmlFor="demo-contact-email">Email</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="demo-contact-sms"
name="contact"
value="sms"
checked={contacts.has("sms")}
onChange={() => toggleContact("sms")}
/>
<Label htmlFor="demo-contact-sms">SMS</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="demo-contact-phone"
name="contact"
value="phone"
checked={contacts.has("phone")}
onChange={() => toggleContact("phone")}
/>
<Label htmlFor="demo-contact-phone">Phone call</Label>
</div>
</div>
<div className="mt-1.5">
{contactError ? (
<FieldError id="demo-contact-error">{contactError}</FieldError>
) : (
<FieldHelp id="demo-contact-help">
Pick every channel that works for you.
</FieldHelp>
)}
</div>
</fieldset>

<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="demo-newsletter">Product newsletter</Label>
<FieldHelp id="demo-newsletter-help">
Monthly. Unsubscribe anytime.
</FieldHelp>
</div>
<Switch
id="demo-newsletter"
name="newsletter"
aria-describedby="demo-newsletter-help"
/>
</div>
</div>
</section>

{/* 4 — a Select composes like any other field --------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
A Select composes like any other field — label, control, helper text,
the same rhythm
</SectionHeading>
<div className="max-w-sm">
<Label id="demo-country-label" htmlFor="demo-country">
Country
</Label>
<div className="mt-2">
<Select name="country" defaultValue="us">
<SelectTrigger
id="demo-country"
aria-labelledby="demo-country-label"
aria-describedby="demo-country-help"
>
<SelectValue placeholder="Choose a country…" />
</SelectTrigger>
<SelectContent aria-label="Country">
<SelectItem value="us">United States</SelectItem>
<SelectItem value="ca">Canada</SelectItem>
<SelectItem value="gb">United Kingdom</SelectItem>
<SelectItem value="au">Australia</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-1.5">
<FieldHelp id="demo-country-help">
Sets your billing currency.
</FieldHelp>
</div>
</div>
</section>

{/* 5 — the whole form, with a submit lifecycle ------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
A whole form — a top-of-form summary on a failed submit (focus moves to
it), a disabled fieldset + busy form + loading button while submitting,
and a success confirmation when it completes
</SectionHeading>

{status === "success" ? (
<Alert intent="success" role="status" className="max-w-sm">
<AlertTitle>You’re on the list</AlertTitle>
<AlertDescription>
We’ve sent a confirmation to your inbox.
</AlertDescription>
</Alert>
) : (
<form
noValidate
aria-busy={status === "submitting" || undefined}
onSubmit={handleSubmit}
className="max-w-sm"
>
{status === "error" && errorList.length > 0 ? (
<div ref={summaryRef} tabIndex={-1} className="mb-6 outline-none">
<Alert intent="destructive" role="alert">
<AlertTitle>
{errorList.length === 1
? "1 field needs attention"
: `${errorList.length} fields need attention`}
</AlertTitle>
<AlertDescription>
<ul className="mt-1 flex flex-col gap-1">
{errorList.map(([id, message]) => (
<li key={id}>
<a
href={`#${id}`}
className="underline underline-offset-2"
>
{SIGNUP_LABELS[id]}
</a>
: {message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
</div>
) : null}

<fieldset
disabled={status === "submitting"}
className="flex min-w-0 flex-col gap-6 border-0 p-0"
>
{/* Structural wrapper for the disabled cascade — its name is
hidden, but every fieldset still carries a legend. */}
<legend className="sr-only">Account details</legend>

<div>
<Label htmlFor="signup-email">Work email</Label>
<div className="mt-2">
<Input
id="signup-email"
name="email"
type="email"
required
autoComplete="email"
value={values["signup-email"]}
onChange={(event) =>
setValue("signup-email", event.target.value)
}
aria-invalid={errors["signup-email"] ? "true" : undefined}
aria-describedby={
errors["signup-email"]
? "signup-email-error"
: "signup-email-help"
}
/>
</div>
<div className="mt-1.5">
{errors["signup-email"] ? (
<FieldError id="signup-email-error">
{errors["signup-email"]}
</FieldError>
) : (
<FieldHelp id="signup-email-help">
Use your company address.
</FieldHelp>
)}
</div>
</div>

<div>
<Label htmlFor="signup-password">Password</Label>
<div className="mt-2">
<Input
id="signup-password"
name="password"
type="password"
required
autoComplete="new-password"
value={values["signup-password"]}
onChange={(event) =>
setValue("signup-password", event.target.value)
}
aria-invalid={errors["signup-password"] ? "true" : undefined}
aria-describedby={
errors["signup-password"]
? "signup-password-error"
: "signup-password-help"
}
/>
</div>
<div className="mt-1.5">
{errors["signup-password"] ? (
<FieldError id="signup-password-error">
{errors["signup-password"]}
</FieldError>
) : (
<FieldHelp id="signup-password-help">
At least 8 characters.
</FieldHelp>
)}
</div>
</div>

<div>
<div className="flex items-center gap-2">
<Checkbox
id="signup-terms"
name="terms"
required
checked={values["signup-terms"] === "checked"}
onChange={(event) =>
setValue(
"signup-terms",
event.target.checked ? "checked" : "",
)
}
aria-invalid={errors["signup-terms"] ? "true" : undefined}
aria-describedby={
errors["signup-terms"] ? "signup-terms-error" : undefined
}
/>
<Label htmlFor="signup-terms">
I agree to the terms of service
</Label>
</div>
{errors["signup-terms"] ? (
<div className="mt-1.5">
<FieldError id="signup-terms-error">
{errors["signup-terms"]}
</FieldError>
</div>
) : null}
</div>

{/* Action row — primary first in DOM order so Enter submits it. */}
<div className="mt-2 flex flex-wrap gap-3">
<Button type="submit" loading={status === "submitting"}>
Create account
</Button>
<Button type="button" variant="ghost">
Cancel
</Button>
</div>
</fieldset>
</form>
)}
</section>

{/* 6 — action layout with a destructive action ----------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
Action layout — a destructive form action sits apart from the confirm /
cancel pair
</SectionHeading>
<div className="flex max-w-sm flex-wrap items-center justify-between gap-3">
<Button variant="destructive">Delete account</Button>
<div className="flex flex-wrap gap-3">
<Button variant="secondary">Save changes</Button>
<Button variant="ghost">Cancel</Button>
</div>
</div>
</section>
</div>
);
}

Variants

A form has no visual variants. Its axes are compositional:

AxisOptionsNotes
LayoutSingle-column (default) · grouped · inlineSingle-column stacks every field at --scale-6. Grouped wraps related fields in <fieldset>. Inline (label and control on one row) is for a one- or two-field form only — a search bar, a filter — never a long form.
Validation displayInline messages (default) · inline plus a top-of-form summaryThe summary is added for long forms and for any submit that can fail server-side; short forms can rely on inline messages alone.
Required communication"Optional" suffix (default)Fields are required by default; optional fields are marked. No required asterisk — see Input's rationale.

Column widths, which fields exist, and how they group are all the composing markup's decision — there is no configuration object.

States

Form-level states, and where each documented control state fits:

StateWhat it looks likeWiring
DefaultHelper text under each field; no aria-invalid anywhereEach visible helper message has an id referenced by its control's aria-describedby
Field invalidThe control's own --destructive border (it already applies this on aria-invalid), plus a TriangleAlert glyph and error text below it in --foregroundaria-invalid="true" on the control while it is invalid; aria-describedby now points at the error message id (space-separated with the helper id when essential guidance stays visible)
Form invalid (after submit)A destructive Alert summary at the top of the form listing each problem as an in-page link (<a href="#field-id">); every failing field also shows its inline errorrole="alert" on the summary; focus is moved to the summary container (tabIndex={-1} + .focus()) so a keyboard / screen-reader user lands on it
Submitting / pendingThe primary Button shows its loading state (spinner replaces the leading icon, label kept, aria-busy, natively disabled); every other control is disabled by a single <fieldset disabled> around the form bodyaria-busy="true" on the <form>; no overlay or spinner scrim
SuccessA success Alert (role="status"), then either the form resets or a read-only confirmation replaces itrole="status" (polite) — a completed submit is not an interruption
Field disabled (not submitting)The field dims as one unit — label, control, and message together — per Input's disabled treatmentNative disabled on the control; a whole disabled section is <fieldset disabled>

Validation timing (guidance, not enforced): validate on submit; after the first submit attempt, re-validate a field on blur so a correction clears its error promptly. Do not validate a field while the user is first typing into it. The error message replaces the helper text by default; keep both visible only when the guidance is still essential after the error appears (e.g. a format rule the user must satisfy) — Input's one exception.

Usage Guidance

Tokens

Form composes the existing field rhythm, each control's invalid-state treatment, Alert's feedback family, and Button's action styles; it introduces no component-specific styling values.

TokenWhere usedRationale
--scale-2Label → control gap within a fieldThe shared form-field model's label gap
--scale-1-5Control → message gap within a fieldThe shared form-field model's message gap
--scale-6Gap between stacked fields / groups, and above the action rowSpacing's "gap between form fields" step
--foregroundLegend text, error message textPrimary reading contrast; error text does not need to be red once the border, icon, message, and aria-invalid all signal the problem
--muted-foregroundHelper text, the "(optional)" suffixThe system's secondary-text pairing
--destructiveInvalid field borderApplied by each control on aria-invalid — Form changes nothing here
--ringFocus ring on every controlUnchanged from each control's own contract
--scale-3-5 + --icon-stroke-widthThe TriangleAlert glyph on an error lineIcons' inline glyph size and stroke
Alert's --{intent}-subtle* familyForm-level error / success AlertReused through Alert, untouched
buttonVariantsThe action rowReused through Button, untouched

Do / Don’t

Do

  • Build a form from the individual controls. Keep each control's own contract intact — a form is a layout around them, never a wrapper that hides them.
  • Give every control a <Label> tied by htmlFor / id, and follow the shared form-field model for the label / control / message rhythm.
  • State the required convention once, near the top ("Fields are required unless marked optional"), and mark the optional fields — not the required ones.
  • Group related controls in a <fieldset> with a visible <legend>. Let a Radio Group provide its own; add one by hand around a set of Checkboxes.
  • Pair an invalid field's --destructive border with both visible error text and the TriangleAlert glyph, and set aria-invalid="true" only while the field is actually invalid.
  • Keep aria-describedby in sync with what is on screen — point it at the helper id by default, the error id once the field is invalid, and both (space-separated) only when essential guidance stays visible alongside the error.
  • On a failed submit, render a top-of-form Alert summary with an in-page link to each failing field, give it role="alert", and move focus to it.
  • Put the primary action first in the DOM so <kbd>Enter</kbd> submits it; render a destructive form action (deleting the record the form edits) apart from the confirm / cancel pair, as variant="destructive".
  • While submitting, wrap the form body in <fieldset disabled>, set aria-busy="true" on the <form>, and give the primary Button its loading state.

Don’t

  • Reach for a <Form> component, a FormField wrapper, or a FormControl that clones its child — none exist, and adding one would hide the contracts this pattern is meant to expose.
  • Build a validation framework, schema, or useForm hook as "part of the form". The application owns the validation mechanism; this page owns only what the user sees and hears.
  • Communicate an error with colour alone — the red border is never sufficient by itself.
  • Use a red asterisk for required fields (see Input).
  • Add aria-required="true" next to a native required attribute — it is redundant.
  • Leave aria-invalid="true" on a field that is not currently invalid, or leave a stale aria-describedby pointing at a message that is no longer rendered.
  • Announce a successful submit with role="alert" — a completion is not an interruption; use role="status".
  • Block the screen with a spinner overlay while submitting — the disabled fieldset and the Button's loading state are the signal.

Accessibility

  • Naming. Every control has a programmatically associated <label>. Every <fieldset> has a <legend> — that is what associates the group's name with each control inside it for assistive tech.
  • Required. The native required attribute carries the semantics on its own. Do not add aria-required. The "unless marked optional" sentence is for sighted users; required is for everyone.
  • Field errors. aria-invalid="true" is added only while a field is invalid and removed on correction. The error message is referenced by the control's aria-describedby, so it is announced when the field takes focus. The TriangleAlert glyph is aria-hidden — it is a redundant visual cue, and the word "error" is carried by the message text, not the icon.
  • Error summary. After a failed submit, a single container gets role="alert" and focus is moved into it (tabIndex={-1}), so the count and the list of problems are announced once and the user is positioned to act. Each list item is a real link to the field's id; following it moves focus to the control.
  • Submitting. aria-busy="true" on the <form> tells assistive tech the form is working. The <fieldset disabled> cascade removes every control from the tab order for the duration, so focus cannot land on a control that will not respond.
  • Success. The confirmation Alert is role="status" (a polite live region) — announced without interrupting whatever the user is doing.
  • Focus order. DOM order is the tab order. The primary action comes first in the DOM so <kbd>Enter</kbd> inside any field submits the intended action; visual order can still place a "Cancel" to its left with layout, but do not reorder the DOM to achieve it.
  • Contrast. Unchanged from the controls' own contracts — --foreground on --background for labels, legends, and error text; --muted-foreground on --background for helper text; both pass WCAG AA in light and dark (measured on Input).
  • Motion. The only animation is the submit Button's spinner, which respects prefers-reduced-motion through Button's own treatment.

Related Components / Patterns

  • Label — names every field.
  • Input — the primary single-line field.
  • Alert — form-level error and success feedback.
  • Button — the submit and cancel actions.