Recipes
Validate a form
Show an error next to a field in a way a screen reader hears, and put focus on the field that needs fixing.
View as Markdown for coding assistants, or see llms.txt.
Validate when the form is submitted, show each message next to its field, and move focus to the first field with a problem.
"use client";
import { Button } from "@azmr/ui/button";
import { Input } from "@azmr/ui/input";
import { type FormEvent, useId, useRef, useState } from "react";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function FormValidation() {
const id = useId();
const input = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
function submit(event: FormEvent) {
event.preventDefault();
if (!EMAIL.test(email)) {
setSent(false);
setError("Enter an email address like aroha@example.co.nz.");
// Put the cursor on the problem, so nobody has to hunt for it.
input.current?.focus();
return;
}
setError(null);
setSent(true);
}
return (
// noValidate turns off the browser's own bubbles, so the message below is the only one.
<form
onSubmit={submit}
noValidate
aria-label="Subscribe"
className="flex max-w-sm flex-col gap-3"
>
<label htmlFor={`${id}-email`} className="text-sm font-medium">
Email
</label>
<Input
id={`${id}-email`}
ref={input}
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
// The red border is not enough on its own: aria-invalid says it, the message explains it,
// and aria-describedby ties the two together for a screen reader.
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? `${id}-error` : undefined}
/>
{error && (
<p id={`${id}-error`} className="text-sm text-destructive">
{error}
</p>
)}
<Button type="submit" className="self-start">
Subscribe
</Button>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sent ? `Subscribed ${email}.` : ""}
</p>
</form>
);
}The pattern
- Put
noValidateon theform, so the browser's own pop-ups do not compete with yours. - On submit, call
preventDefault(), check the values and store a message for each problem. - For an invalid Input, set
aria-invalid="true"andaria-describedbyto the id of its message. Only set them while there is an error. - Move focus to the first invalid field. A keyboard user is left exactly where the problem is, and a screen reader reads the label, the state and the message.
- Write the message as the fix ("Enter an email address like aroha@example.co.nz."), not the rule ("Invalid format").
Do not rely on colour. The red border is a reinforcement: aria-invalid and the text carry the meaning.
For a whole form with several fields, an error summary and a saving state, see the settings page example.