Ecommerce Boilerplate
Features

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:

src/components/ui/form.tsx
    <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:

src/components/login/LoginForm.tsx
<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.

src/components/home/NewsletterSignup.tsx
<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":

src/components/ui/form.tsx
      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.

Radios are the case where the native platform does the accessibility work, and only if you let it. src/components/userProfile/AddressForm.tsx:

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:

src/components/userProfile/AddressForm.tsx
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.

One SkipLinkGate in the root layout, first in the tab order, visually hidden until it takes focus:

src/app/[locale]/layout.tsx
<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:

src/app/[locale]/admin/layout.tsx
<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:

src/components/a11y/SkipLinkGate.tsx
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.

src/lib/i18n/en.ts
    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 it tabIndex={-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:

src/lib/motion.ts
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:

src/components/home/NewsletterSignup.tsx
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:

tests/motion.test.ts
    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:

e2e/specs/accessibility.spec.ts
      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:

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:

src/app/globals.css
  @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.

CoveredAsserted as
The sign-in field's accessible namegetByLabel finds it, and the message id is in its aria-describedby
Error announcementThe message resolves by role="alert", and aria-invalid is on the input
Mini-cart control namesEach button found by its interpolated action-plus-item name
The storefront skip linkTab focuses it, Enter moves focus onto #main-content
The admin landmarkmain#admin-main exists, and the link's href points at it
Address form labelsFive fields located by label text, not placeholder
Radio groupingBoth 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:

TokenHeld toBecause
--input3:1It is the entire boundary of a text field
--ring3:1It is the entire focus indicator
--bordera perceptible floor, not 3:1Card 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.

  1. Every interactive control has an accessible name, and the name says what the control does to which object.
  2. Every form field has FormLabel + FormControl (wrapping the control) + FormMessage, and the form has noValidate.
  3. Every shopper-facing aria-label and sr-only string exists in both dictionaries. The admin dashboard is deliberately English-only — no admin file calls useTranslations and there is no admin section in the dictionary — so a new admin label is a plain string, and adding one to en.ts alone would be the inconsistency.
  4. Anything animated goes through src/lib/motion.ts.
  5. Anything that renders behind the chrome keeps focus reachable — no focusable element hidden from the accessibility tree, no outline: none without a replacement focus style.
  6. Write the spec with getByRole and getByLabel. If the locator cannot find your control, you have your first bug before you have shipped it.

On this page