Accessibility
The accessibility contract this repository enforces, and the E2E suite that proves it.
This is a contract for contributors, not an introduction to WCAG. Everything below was broken in this repository at some point, shipped, and survived review — the sign-in field had no accessible name for months. The rules exist because the failures are silent: nothing crashes, nothing logs, and the page looks correct in a screenshot.
Most of it is asserted in a real browser by e2e/specs/accessibility.spec.ts.
The parts that are not are called out at the bottom, plainly.
The FormControl trap
Read this before you touch a form.
FormControl in src/components/ui/form.tsx is a Radix Slot. It renders no
element of its own — it merges its props onto its single immediate child:
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>Three attributes ride on that merge: the generated id that FormLabel's
htmlFor points at, the aria-describedby that reaches the error message, and
aria-invalid. Put a layout wrapper inside FormControl and all three land on
the wrapper.
Nothing fails. TypeScript is happy — a div accepts every one of those props.
The form submits, validates, and renders its red text. What breaks is invisible
from the outside: the label's htmlFor points at a div, which is not a
labelable element, so the input has no accessible name and a screen reader
falls back to the placeholder. aria-invalid sits on a div, which has no
validity to report. And the validation message is described-by nothing at all.
This is the shape that shipped in the sign-in form. The icon wrapper was inside:
{/* Wrong. The div is FormControl's single child, so it collects
the id, the aria-describedby and the aria-invalid. */}
<FormControl>
<div className="relative">
<Mail aria-hidden="true" />
<Input type="email" {...field} />
</div>
</FormControl>The fix is to move the wrapper out, so the control itself is the slot's child:
<div className="relative">
<Mail
className="pointer-events-none absolute left-3.5 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<FormControl>
<Input
type="email"
required
aria-required="true"
autoComplete="email"
inputMode="email"
placeholder={t("auth.emailPlaceholder")}
{...field}
/>
</FormControl>
</div>The rule, stated once: FormControl wraps the control, never the layout.
Decoration, icons, prefixes, and positioning wrappers all sit outside it.
The form checklist
Every field in a FormField gets all of this. There is no field in this
repository that is exempt, and the parts that look cosmetic are not.
A FormLabel, always. When the design has no room for one — a single-field
inline signup, a search box — the label is still there, sr-only. It is the
only thing that gives the control an accessible name that survives the first
keystroke, which is exactly what a placeholder does not do.
<FormLabel className="sr-only">
{t("home.newsletterLabel")}
</FormLabel>
<FormControl>
<Input
type="email"
autoComplete="email"
placeholder={t("home.newsletterPlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />FormControl around the control itself. See above. If you find yourself
wrapping anything with a className that positions, you are in the trap.
A FormMessage. It is what carries the error, and it renders nothing when
there is no error, so there is no reason to leave it out.
noValidate on the <form>. zod owns validation here. The browser's own
constraint bubble is untranslated — it says "Please fill out this field" in the
browser's language, not the store's — is not reliably announced, and it fires
first, pre-empting the message that is actually wired to the field.
autoComplete tokens on address and contact fields. street-address,
address-level2, address-level1, postal-code, email, tel. This is an
accessibility feature before it is a convenience one: for a user who types
slowly or with difficulty, the browser's saved address is the fastest path
through a checkout, and without the tokens it cannot offer one.
required plus aria-required where the schema demands a value, and a
visible marker that is not colour alone. The pattern in LoginForm is an
aria-hidden asterisk for sighted users and an sr-only common.required
for everyone else, so the requirement is announced rather than inferred from a
red glyph.
Why FormMessage is a live region
FormMessage sets role="alert":
id={formMessageId}
role="alert"
className={cn("text-sm font-medium text-destructive", className)}aria-describedby is only read out while the described field has focus. That
covers the case where you tab away from a bad value, and it covers nothing
else. The universal case — press Submit, three errors appear under three
fields — announced nothing. react-hook-form focuses the first invalid
field, which rescues that one message by accident; the other two were red text
on a screen and no more.
role="alert" makes the message an assertive live region, so it is announced
when it appears regardless of where focus is. Keep it on any error text you add
outside this component.
Grouping related controls
Radios are the case where the native platform does the accessibility work, and
only if you let it. src/components/userProfile/AddressForm.tsx:
<fieldset className="flex flex-col mx-2 space-y-1.5">
<legend className="font-semibold">Billing Details</legend>Two things are load-bearing there. A real <fieldset>/<legend> names the
group — the previous version used a bare <label> with no htmlFor, a heading
wearing a label's clothes, which named nothing. And a shared name is what
makes two radios one group; without it the browser treats them as independent
controls, arrow keys do nothing (a radio's only keyboard interaction), and each
is a separate tab stop.
The name and the ids are scoped with useId rather than hardcoded:
const formId = useId();
...
<input
className="h-4 w-4 accent-brand-accent"
id={`${formId}-individual`}
name={`${formId}-billingType`}
type="radio"
checked={field.value === "individual"}
onChange={() => field.onChange("individual")}
/>Global id strings work until the second instance of the component mounts — an
edit dialog over a list of addresses, say — and then one form's label binds to
the other form's input. useId costs one line and removes the whole class.
Skip links and landmarks
One SkipLinkGate in the root layout, first in the tab order, visually hidden
until it takes focus:
<SkipLinkGate />
<BoilerplateBar />
<HeaderGate />
<div id="main-content" tabIndex={-1} className="outline-none">
{children}
</div>Three decisions worth knowing before you change any of it.
tabIndex={-1} on the target is not optional. A div is not focusable.
Follow a fragment link to one and the browser scrolls the element into view and
leaves focus exactly where it was — at the top of the document. The next Tab
then restarts at the navigation, which is the thing the visitor just asked to
skip. tabIndex={-1} makes the target programmatically focusable so focus
actually lands there. The E2E suite asserts this specifically, because a skip
link that scrolls without moving focus looks correct to everyone testing with a
mouse.
The target wraps {children} in the layout, not each route's own <main>.
Every page an adopter adds inherits the skip target without anyone remembering
to add an id.
Admin got its own target rather than a second link. The admin dashboard renders inside the storefront layout but supplies its own header, sidebar and main:
<main id="admin-main" tabIndex={-1} className="min-w-0 flex-1 outline-none">
{children}
</main>Pointing the storefront link at #main-content from an admin route would land
the visitor before the ten sidebar links they were trying to jump over.
Rendering a second link would put two skip links in a row, and neither would be
reliably first. Choosing the target by pathname is the version that works:
const pathname = usePathname();
return (
<SkipLink targetId={isAdminRoute(pathname) ? "admin-main" : "main-content"} />
);On the storefront, landmarks alone would satisfy WCAG 2.4.1 — every route
renders a main. The link exists for admin, where the sidebar put a dozen tab
stops in front of the content on every page. It stays on the storefront so
there is one component and one behaviour, not two.
Naming controls
Name the action and the object, not the object alone. The mini-cart once labelled its decrement button "Remove" and its increment button "Cart" — so a keyboard-and-screen-reader user heard two controls called "Remove" per line, one of which decremented, and one called "Cart" that added an item.
An icon-only button in a list must include the item name. Pull up the buttons list in any screen reader on a cart with six lines and identical labels give you six indistinguishable entries — and one of them deletes something.
decreaseQty: "Decrease quantity of {name}",
increaseQty: "Increase quantity of {name}",
removeLine: "Remove {name} from cart",The interpolation is the point. A label that is a bare verb is a label that only works when there is exactly one of the thing.
Two corollaries:
- An icon inside a labelled button gets
aria-hidden="true". Otherwise its title or its path data can leak into the computed name. - If a control is decorative or duplicates a real control that is already in
the tab order, mark it
aria-hidden="true"and give ittabIndex={-1}. Hiding it from the accessibility tree while leaving it focusable produces a tab stop that announces nothing, which is worse than either.
Accessible names are translated copy
An aria-label or an sr-only string is what a screen reader announces
instead of the visible text, so on the storefront it is shopper-facing copy:
it belongs in both src/lib/i18n/en.ts and src/lib/i18n/es.ts, and
tests/i18n-es.test.ts fails the build when a key is in one dictionary and not
the other. Otherwise /es reads as Spanish and sounds like English.
The admin dashboard is a deliberate exception — it is English-only, no admin
file calls useTranslations, and the dictionary has no admin section. A new
admin label is a plain string there, and routing one through t() would be
the thing out of place.
Motion
src/lib/motion.ts is the single source of truth for storefront animation, and
every factory in it takes a reduce flag and returns the finished state
instead of the entrance:
export function revealUp(delay = 0, reduce = false) {
if (reduce) return STILL;
return {
initial: { opacity: 0, y: 24 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.3 },
transition: { duration: DUR.base, delay, ease: EASE },
};
}STILL is { initial: false, animate: { opacity: 1, y: 0, scale: 1 } }, and
the initial: false is the load-bearing half.
This branch used to return {}, and that made every animated element on the
site permanently invisible under prefers-reduced-motion. The server has no
matchMedia, so useReducedMotion() is false during SSR and the factory
hands back the full animation — which framer-motion renders as
style="opacity:0;transform:translateY(20px)" in the HTML. On the client the
hook reports true, the factory returns no animation props at all, and
framer-motion has nothing to animate: it never touches the element, and the
opacity the server wrote stays at zero. The headline was in the DOM,
reachable by a screen reader, and not on the screen.
So the reduced branch has to assert the end state rather than abstain.
initial: false tells framer-motion to mount at the animate values instead
of transitioning to them, which overwrites the server's inline style with no
motion at all. Call sites read the preference once and thread it:
const reduce = useReducedMotion() ?? false;
...
{...revealScale(0, reduce)}Keep the factory pattern when you add an entrance. A component that writes its
own initial/animate inline is a component that has quietly opted out of
reduced motion, and nothing will tell you — except that a bare
initial={reduce ? undefined : …} reproduces the invisibility bug above, so
write initial={reduce ? false : …} and keep animate unconditional.
tests/motion.test.ts asserts the contract for every factory:
expect(p.initial).toBe(false);
expect(p.animate).toEqual(FINISHED);
// No `transition`, no `whileInView`, no `variants`: nothing that could
// animate, and nothing that waits for a viewport intersection.
expect(p.transition).toBeUndefined();and e2e/specs/accessibility.spec.ts checks the rendered result in a browser
running with the preference set, because the unit test above passed the whole
time the page was blank:
const context = await browser.newContext({ reducedMotion: "reduce" });CSS animations are not covered by that flag, so they carry their own query in
src/app/globals.css:
@media (prefers-reduced-motion: reduce) {
.marquee-track, .conic-sheen, .type-caret { animation: none; }
.glass-hover:hover { transform: none; }
}The storefront's glass panels honour a second preference for the same class of reader — the backdrop blur is dropped for an opaque surface rather than softened:
@media (prefers-reduced-transparency: reduce) {
.glass, .glass-strong {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: hsl(var(--popover));
}
}What is tested, and what is not
e2e/specs/accessibility.spec.ts runs in npm run test:e2e against a built
store in real Chromium. Every case in it was broken before the commit that
added the file.
| Covered | Asserted as |
|---|---|
| The sign-in field's accessible name | getByLabel finds it, and the message id is in its aria-describedby |
| Error announcement | The message resolves by role="alert", and aria-invalid is on the input |
| Mini-cart control names | Each button found by its interpolated action-plus-item name |
| The storefront skip link | Tab focuses it, Enter moves focus onto #main-content |
| The admin landmark | main#admin-main exists, and the link's href points at it |
| Address form labels | Five fields located by label text, not placeholder |
| Radio grouping | Both radios share one non-null name |
It deliberately covers neither of these:
- Colour contrast. A headless browser can read a computed colour, but contrast is a property of the palette, not of a rendered page — checking it per-route means checking the same four tokens a hundred times and missing the fifth. It belongs in a unit test over the tokens, where it runs in a second and points at the line to change.
- Anything requiring a real screen reader. Playwright can prove an accessible name is computed; it cannot tell you what VoiceOver, NVDA, or TalkBack actually said, in what order, or whether the result was comprehensible. Claiming otherwise would be the more dangerous outcome than not testing it — a green suite that means less than a reader assumes. Reading a new flow with an actual screen reader remains a manual step.
Selecting by role and accessible name in every spec, not just this one, is how most of these were found in the first place. A locator that cannot find a button by its name has not found a flaky test; it has found a control with no name. See Testing for the convention.
Contrast is a token rule
Because it is not in the browser suite, contrast is guarded where it actually
lives — the palette in src/app/globals.css.
The rule is a split, and the split is the interesting part. A token that is the only thing telling you where a control is must clear 3:1 — what WCAG 1.4.11 asks of non-text UI. A token that is pure decoration, redundant with a shadow, a fill change or spacing, must only stay perceptible.
tests/design-tokens.test.ts enforces both, with a real relative-luminance
implementation over the values parsed out of globals.css, so a palette edit
that regresses one fails the build:
| Token | Held to | Because |
|---|---|---|
--input | 3:1 | It is the entire boundary of a text field |
--ring | 3:1 | It is the entire focus indicator |
--border | a perceptible floor, not 3:1 | Card edges and separators carry no information on their own, and a 3:1 outline on every one turns the admin into a wireframe |
The --border exemption is a judgement, not an oversight, and it is written
into the test beside the threshold. If --border ever becomes the only marker
of something — a selected row, a drop target — it moves to 3:1 with it.
Brand colours are out of scope entirely: they are a deferred branding decision, and the test parses them without asserting on them so the exemption is visible rather than silent.
--input is the token that made this rule necessary. An input border you
cannot distinguish from the page is not a border, and it fails for everyone in
sunlight, not only for the people the criterion is written for.
Adding or restyling a palette token means adding it to that test in the same commit. A token nobody checks is a token that regresses on the next visual tweak, which is precisely how this one got in.
When you add a component
Short version, in the order the failures actually happen.
- Every interactive control has an accessible name, and the name says what the control does to which object.
- Every form field has
FormLabel+FormControl(wrapping the control) +FormMessage, and the form hasnoValidate. - Every shopper-facing
aria-labelandsr-onlystring exists in both dictionaries. The admin dashboard is deliberately English-only — no admin file callsuseTranslationsand there is noadminsection in the dictionary — so a new admin label is a plain string, and adding one toen.tsalone would be the inconsistency. - Anything animated goes through
src/lib/motion.ts. - Anything that renders behind the chrome keeps focus reachable — no
focusable element hidden from the accessibility tree, no
outline: nonewithout a replacement focus style. - Write the spec with
getByRoleandgetByLabel. If the locator cannot find your control, you have your first bug before you have shipped it.