Accessible forms and validation feedback
Checkout is where a shop makes money, and a form that is hard to use is where it loses it. Accessibility here is not a separate concern from usability — the same fixes help everyone, and most of them take one attribute.
Every input needs a label
<input placeholder="Email" /> // no
<label htmlFor="email">Email</label> // yes
<input id="email" />
A placeholder is not a label. It vanishes when typing starts, so anyone who forgets what a field was for has to clear it to find out. It also fails contrast requirements in most designs, and screen readers treat it inconsistently.
The Input component from the component library lesson makes label required,
which is the point.
When the design genuinely has no room, hide it visually but keep it for screen readers:
<label htmlFor="search" className="sr-only">Search products</label>
<input id="search" placeholder="Search products" />
sr-only is a Tailwind class that positions the element off-screen without
display: none — which would hide it from screen readers too.
The right input type
On a phone, the type changes the keyboard that appears:
| Field | Type and attributes |
|---|---|
type="email" autoComplete="email" inputMode="email" |
|
| Phone | type="tel" autoComplete="tel" inputMode="tel" |
| Pincode | inputMode="numeric" pattern="[0-9]*" autoComplete="postal-code" |
| Name | type="text" autoComplete="name" |
| Address | autoComplete="street-address" |
| City | autoComplete="address-level2" |
| Password | type="password" autoComplete="current-password" |
| New password | type="password" autoComplete="new-password" |
| OTP | inputMode="numeric" autoComplete="one-time-code" |
autoComplete is not optional on a checkout form. It lets the browser fill
an address in one tap. Leaving it out means your customer types their address by
hand on a phone, and a meaningful share of them will not finish.
autoComplete="one-time-code" lets iOS and Android offer an SMS code
automatically, which is worth knowing when you build OTP login.
Avoid type="number" for anything that is not a quantity. It permits
e, + and -, browsers add spinners you do not want, and scrolling over it
changes the value. For a pincode, inputMode="numeric" gives the numeric
keyboard without any of that.
Errors people can act on
Three rules.
Say what to do, not just what is wrong.
Invalid pincode poor
Pincode must be 6 digits better
Put the message next to the field, not only in a summary at the top.
Never rely on colour alone. Red text is invisible to a colour-blind user
and to anyone with a screen reader. Pair it with words and aria-invalid.
<input
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && <p id={errorId} className="text-xs text-red-600">{error}</p>}
aria-describedby is what makes the message reach a screen reader when the
field is focused. Without it, the text is decoration.
When to validate
Not while typing. Showing "invalid email" after the first character is hostile — the user knows they are not finished.
The pattern that works:
- On blur for the first validation of a field
- On change afterwards, once it has an error, so the message clears as soon as they fix it
- On submit for everything
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
type Errors = Partial<Record<"name" | "phone" | "pincode", string>>;
function validate(values: Record<string, string>): Errors {
const errors: Errors = {};
if (!values.name.trim()) errors.name = "Name is required";
if (!/^[6-9]\d{9}$/.test(values.phone))
errors.phone = "Enter a 10-digit mobile number";
if (!/^\d{6}$/.test(values.pincode)) errors.pincode = "Pincode must be 6 digits";
return errors;
}
export function AddressForm({ onSave }: { onSave: (v: Address) => Promise<void> }) {
const [values, setValues] = useState({ name: "", phone: "", pincode: "" });
const [errors, setErrors] = useState<Errors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;
setValues((v) => ({ ...v, [name]: value }));
if (touched[name]) {
setErrors(validate({ ...values, [name]: value }));
}
}
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
setTouched((t) => ({ ...t, [e.target.name]: true }));
setErrors(validate(values));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (submitting) return;
const found = validate(values);
setErrors(found);
setTouched({ name: true, phone: true, pincode: true });
if (Object.keys(found).length > 0) {
document.querySelector<HTMLInputElement>("[aria-invalid='true']")?.focus();
return;
}
setSubmitting(true);
setFormError(null);
try {
await onSave(values as Address);
} catch {
setFormError("We could not save your address. Please try again.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{formError && (
<p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{formError}
</p>
)}
<Input
label="Full name"
name="name"
autoComplete="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
error={touched.name ? errors.name : undefined}
/>
<Input
label="Mobile number"
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
hint="We will send delivery updates here"
value={values.phone}
onChange={handleChange}
onBlur={handleBlur}
error={touched.phone ? errors.phone : undefined}
/>
<Input
label="Pincode"
name="pincode"
inputMode="numeric"
autoComplete="postal-code"
maxLength={6}
value={values.pincode}
onChange={handleChange}
onBlur={handleBlur}
error={touched.pincode ? errors.pincode : undefined}
/>
<Button
type="submit"
className="w-full"
aria-busy={submitting}
{...(submitting && { "aria-disabled": true })}
>
{submitting ? "Saving…" : "Save address"}
</Button>
</form>
);
}
Points worth pulling out.
noValidate turns off the browser's own messages so yours are consistent
across browsers. You still get the mobile keyboard from type and inputMode.
Focus moves to the first invalid field on failed submit. Without it, a keyboard or screen-reader user has no idea anything went wrong — the message is below the fold and focus never moved.
role="alert" on the form-level error makes a screen reader announce it
immediately.
The button is not disabled, for the reason from the forms lesson. aria-busy
and aria-disabled communicate the state; the if (submitting) return guard
prevents the double submit.
/^[6-9]\d{9}$/ — Indian mobile numbers are ten digits starting 6 to 9.
Validate for your actual users rather than copying a generic pattern.
Keyboard
Test every form by putting your mouse away.
Tabreaches every field in a sensible order- Focus is always visible — that is
focus-visible:ring-2 Enterin any field submits, which needsonSubmiton the formEscapecloses any dropdown or modal- Nothing is reachable only by hovering
If you cannot complete the form with the keyboard, it is broken. Not "less accessible" — broken, for people using a screen reader, people with motor impairments, and anyone whose trackpad has died.
Required fields
<Input label="Full name" required aria-required="true" />
Mark optional fields rather than required ones when most are required — less
visual noise. If you use an asterisk, explain it once above the form; an
unexplained * means nothing to a screen reader unless you add text.
Check your work
Why a placeholder is not a label: it disappears when typing begins, usually fails contrast, and is announced inconsistently by screen readers.
What sr-only does: hides an element visually while keeping it available to
screen readers. display: none would hide it from both.
Why autoComplete matters commercially: it lets a browser fill an address in
one tap. Without it people type an address on a phone, and some abandon the
checkout.
Why not type="number" for a pincode: it accepts e, + and -, adds
spinners, and changes on scroll. inputMode="numeric" gives the keyboard without
the behaviour.
When to validate: on blur first, on change once a field already has an error, and on submit for everything. Never on the first keystroke.
Why focus the first invalid field: otherwise a keyboard or screen-reader user gets no indication that submission failed.
Why aria-describedby: it connects the error text to the input so it is
announced. Colour alone communicates nothing.
Indian mobile pattern: /^[6-9]\d{9}$/ — ten digits starting 6 to 9.
Practice
- Build the address form. Complete it using only the keyboard.
- Replace a label with a placeholder. Fill the field, then try to remember what it was for.
- Add
sr-onlylabels to a search field and confirm a screen reader still announces it. - Remove every
autoCompleteand try filling the form on a phone. Add them back. - Make the pincode
type="number"and try typingeand-. - Validate on every keystroke. Notice how hostile it feels, then switch to blur.
- Submit an invalid form and confirm focus moves to the first bad field.
- Turn off colour vision in DevTools rendering options and confirm you can still tell which field failed.
- Tab through the form with your eyes closed to the screen and describe what a screen reader user would experience.
- Run Lighthouse's accessibility audit on the page and fix anything it finds.
Next: deciding what state belongs in the browser at all.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship