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 thetext-sm font-medium leading-snug text-foregroundrecipe (the same one Radio Group's legend uses). A group-level helper or error is a normal message, associated to the<fieldset>througharia-describedby(andaria-invalidon 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
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
Action layout — a destructive form action sits apart from the confirm / cancel pair
Code Example
"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:
| Axis | Options | Notes |
|---|---|---|
| Layout | Single-column (default) · grouped · inline | Single-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 display | Inline messages (default) · inline plus a top-of-form summary | The 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:
| State | What it looks like | Wiring |
|---|---|---|
| Default | Helper text under each field; no aria-invalid anywhere | Each visible helper message has an id referenced by its control's aria-describedby |
| Field invalid | The control's own --destructive border (it already applies this on aria-invalid), plus a TriangleAlert glyph and error text below it in --foreground | aria-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 error | role="alert" on the summary; focus is moved to the summary container (tabIndex={-1} + .focus()) so a keyboard / screen-reader user lands on it |
| Submitting / pending | The 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 body | aria-busy="true" on the <form>; no overlay or spinner scrim |
| Success | A success Alert (role="status"), then either the form resets or a read-only confirmation replaces it | role="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 treatment | Native 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.
| Token | Where used | Rationale |
|---|---|---|
--scale-2 | Label → control gap within a field | The shared form-field model's label gap |
--scale-1-5 | Control → message gap within a field | The shared form-field model's message gap |
--scale-6 | Gap between stacked fields / groups, and above the action row | Spacing's "gap between form fields" step |
--foreground | Legend text, error message text | Primary reading contrast; error text does not need to be red once the border, icon, message, and aria-invalid all signal the problem |
--muted-foreground | Helper text, the "(optional)" suffix | The system's secondary-text pairing |
--destructive | Invalid field border | Applied by each control on aria-invalid — Form changes nothing here |
--ring | Focus ring on every control | Unchanged from each control's own contract |
--scale-3-5 + --icon-stroke-width | The TriangleAlert glyph on an error line | Icons' inline glyph size and stroke |
Alert's --{intent}-subtle* family | Form-level error / success Alert | Reused through Alert, untouched |
buttonVariants | The action row | Reused 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 byhtmlFor/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
--destructiveborder with both visible error text and theTriangleAlertglyph, and setaria-invalid="true"only while the field is actually invalid. - Keep
aria-describedbyin sync with what is on screen — point it at the helperidby default, the erroridonce 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>, setaria-busy="true"on the<form>, and give the primary Button itsloadingstate.
Don’t
- Reach for a
<Form>component, aFormFieldwrapper, or aFormControlthat clones its child — none exist, and adding one would hide the contracts this pattern is meant to expose. - Build a validation framework, schema, or
useFormhook 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 nativerequiredattribute — it is redundant. - Leave
aria-invalid="true"on a field that is not currently invalid, or leave a stalearia-describedbypointing at a message that is no longer rendered. - Announce a successful submit with
role="alert"— a completion is not an interruption; userole="status". - Block the screen with a spinner overlay while submitting — the disabled fieldset and the
Button's
loadingstate 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
requiredattribute carries the semantics on its own. Do not addaria-required. The "unless marked optional" sentence is for sighted users;requiredis 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'saria-describedby, so it is announced when the field takes focus. TheTriangleAlertglyph isaria-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'sid; 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 —
--foregroundon--backgroundfor labels, legends, and error text;--muted-foregroundon--backgroundfor 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-motionthrough Button's own treatment.