Skip to content

DOMAIN:FRONTEND:ACCESSIBILITY

OWNER: julian (domain), floris (Team Alpha implementation), floor (Team Beta implementation)
UPDATED: 2026-03-24
SCOPE: all client projects — WCAG 2.1 AA compliance is MANDATORY
LEGAL_CONTEXT: European Accessibility Act (EAA) enforced since June 2025. Fines up to EUR 500,000.
ALSO_CHECK: domains/accessibility/index.md (EN 301 549, EAA, compliance frameworks)


COMPLIANCE_TARGET

STANDARD: WCAG 2.1 Level AA
LEGAL_BASIS: European Accessibility Act (Directive 2019/882), EN 301 549 v3.2.1
SCOPE: all products and services offered to EU consumers (GE's entire client base)
ENFORCEMENT: national market surveillance authorities. Netherlands: Dutch Human Rights Institute.
NOTE: WCAG 2.2 adds 9 new criteria. GE targets 2.1 AA as baseline. 2.2 AA is recommended for new projects.


SEMANTIC_HTML

RULE: use semantic HTML elements as the primary accessibility mechanism. ARIA is a supplement, not a replacement.

ELEMENTS

// GOOD: semantic elements communicate meaning to assistive tech
<header>...</header>
<nav aria-label="Main navigation">...</nav>
<main>...</main>
<aside>...</aside>
<footer>...</footer>
<article>...</article>
<section aria-labelledby="section-title">
  <h2 id="section-title">Section Title</h2>
</section>

// BAD: divs with roles recreating native semantics
<div role="navigation">...</div>
<div role="main">...</div>
<div role="banner">...</div>

HEADINGS

RULE: one <h1> per page. Heading hierarchy must not skip levels (h1 -> h3 is invalid).
RULE: headings describe content structure. Never use heading tags for visual sizing.

// GOOD: proper hierarchy
<h1>Dashboard</h1>
  <h2>Recent Orders</h2>
    <h3>Order #1234</h3>
  <h2>Statistics</h2>
    <h3>Revenue</h3>
    <h3>Users</h3>

// BAD: skipped levels, used for visual sizing
<h1>Dashboard</h1>
  <h4>Recent Orders</h4>  {/* Skipped h2, h3 */}
  <h2>Some section styled smaller than h4</h2>

INTERACTIVE_ELEMENTS

RULE: use <button> for actions, <a> for navigation. Never use <div onClick> or <span onClick>.

// GOOD: native button — keyboard accessible, focusable, announced as button
<button onClick={handleSave}>Save</button>

// GOOD: link navigates to a URL
<Link href="/settings">Settings</Link>

// BAD: div as button — not focusable, not announced, no keyboard support
<div onClick={handleSave} className="cursor-pointer">Save</div>

// BAD: button used for navigation
<button onClick={() => router.push("/settings")}>Settings</button>

IF element performs action THEN use <button>
IF element navigates to URL THEN use <a> or <Link>
IF element toggles state THEN use <button> with aria-pressed or <input type="checkbox">
IF element selects from options THEN use <select> or Radix Select


ARIA_PATTERNS

FIRST_RULE_OF_ARIA

"No ARIA is better than bad ARIA." — W3C WAI

USE_ARIA_WHEN: native HTML cannot express the semantics (custom widgets, dynamic content, state).
DO_NOT_USE_ARIA: to duplicate what native HTML already provides.

// BAD: redundant ARIA on native element
<button role="button" aria-label="Save">Save</button>

// GOOD: ARIA adds information that HTML cannot express
<button aria-expanded={isOpen} aria-controls="menu-panel">
  Menu
</button>
<div id="menu-panel" role="menu" hidden={!isOpen}>
  {/* menu items */}
</div>

LIVE_REGIONS

USE_WHEN: content updates dynamically without user interaction (notifications, chat, live data).

// Polite: announced after current speech completes
<div aria-live="polite" aria-atomic="true">
  {statusMessage}
</div>

// Assertive: interrupts current speech (errors, urgent notifications only)
<div role="alert" aria-live="assertive">
  {errorMessage}
</div>

// Status: polite announcement for status updates
<div role="status" aria-live="polite">
  {searchResults.length} results found
</div>

RULE: use polite by default. assertive only for errors and time-critical alerts.
ANTI_PATTERN: marking a large content area as live region — screen reader announces every change.

COMMON_ARIA_ATTRIBUTES

// Labeling
aria-label="Close dialog"           // When no visible text label exists
aria-labelledby="heading-id"        // Points to visible heading as label
aria-describedby="help-text-id"     // Points to supplementary description

// State
aria-expanded={true|false}          // Collapsible sections, dropdowns
aria-selected={true|false}          // Tabs, list items
aria-checked={true|false|"mixed"}   // Checkboxes, toggles
aria-pressed={true|false}           // Toggle buttons
aria-current="page"                 // Current page in navigation
aria-disabled={true}                // Disabled but still visible/focusable
aria-busy={true}                    // Loading state
aria-invalid={true}                 // Form validation error

// Relationships
aria-controls="panel-id"            // This element controls that element
aria-owns="child-id"                // Logical parent when DOM order differs
aria-haspopup="dialog"|"menu"       // This element triggers a popup

KEYBOARD_NAVIGATION

RULE: every interactive element must be operable with keyboard only.
RULE: focus order must match visual order (no tabindex > 0).

FOCUS_MANAGEMENT

"use client";

import { useRef, useEffect } from "react";

export function Modal({ open, onClose, title, children }: ModalProps) {
  const closeRef = useRef<HTMLButtonElement>(null);

  // Move focus to close button when modal opens
  useEffect(() => {
    if (open) closeRef.current?.focus();
  }, [open]);

  // Trap focus inside modal (Radix Dialog does this automatically)
  return (
    <div role="dialog" aria-modal="true" aria-labelledby="modal-title">
      <h2 id="modal-title">{title}</h2>
      {children}
      <button ref={closeRef} onClick={onClose}>
        Close
      </button>
    </div>
  );
}

NOTE: if using shadcn/ui Dialog (Radix), focus trapping and management is handled automatically. Do NOT implement custom focus trapping with shadcn dialogs.

KEYBOARD_PATTERNS

Component Expected Keyboard Behavior
Button Enter/Space activates
Link Enter activates
Dropdown Arrow keys navigate, Enter selects, Escape closes
Dialog/Modal Tab cycles within, Escape closes, focus trapped
Tabs Arrow keys switch tabs, Tab moves to panel
Menu Arrow keys navigate, Enter selects, Escape closes
Accordion Enter/Space toggles section
Checkbox Space toggles
Radio group Arrow keys move selection

NOTE: Radix UI primitives implement all these patterns. Use Radix/shadcn instead of building custom.

RULE: every page must have a "skip to main content" link as the first focusable element.

// components/layouts/skip-link.tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:shadow-lg"
    >
      Skip to main content
    </a>
  );
}

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SkipLink />
        <header>...</header>
        <main id="main-content">{children}</main>
      </body>
    </html>
  );
}

FOCUS_VISIBLE

RULE: never remove focus outlines globally. Use focus-visible for keyboard-only focus styles.

// GOOD: visible focus ring for keyboard users, hidden for mouse users
<button className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
  Click me
</button>

// BAD: removes focus outline for everyone
<button className="outline-none focus:outline-none">Click me</button>

COLOR_AND_CONTRAST

CONTRAST_RATIOS

WCAG_AA_NORMAL_TEXT: 4.5:1 contrast ratio (text < 18pt or < 14pt bold)
WCAG_AA_LARGE_TEXT: 3:1 contrast ratio (text >= 18pt or >= 14pt bold)
WCAG_AA_UI_COMPONENTS: 3:1 contrast ratio (borders, icons, focus indicators)

/* GOOD: sufficient contrast */
--color-foreground: oklch(0.145 0 0);     /* Near black on white = high contrast */
--color-muted-foreground: oklch(0.4 0 0); /* Dark gray on white >= 4.5:1 */

/* BAD: insufficient contrast */
--color-muted-foreground: oklch(0.7 0 0); /* Light gray on white < 4.5:1 */

TOOL: use Chrome DevTools contrast checker (inspect element > color picker shows ratio).
TOOL: WebAIM Contrast Checker (https://webaim.org/resources/contrastchecker/).

COLOR_NOT_SOLE_INDICATOR

RULE: never use color alone to convey information.

// BAD: only color indicates error
<input className={hasError ? "border-red-500" : "border-gray-300"} />

// GOOD: color + icon + text
<div>
  <input
    className={hasError ? "border-destructive" : "border-input"}
    aria-invalid={hasError}
    aria-describedby={hasError ? "error-msg" : undefined}
  />
  {hasError && (
    <p id="error-msg" className="flex items-center gap-1 text-sm text-destructive">
      <AlertCircle className="h-4 w-4" />
      Email is required
    </p>
  )}
</div>

REDUCED_MOTION

RULE: respect prefers-reduced-motion for users who experience motion sickness or vestibular disorders.

/* Tailwind: motion-safe and motion-reduce variants */
.animate-bounce { animation: bounce 1s infinite; }

@media (prefers-reduced-motion: reduce) {
  .animate-bounce { animation: none; }
}
// Tailwind utility classes
<div className="motion-safe:animate-bounce motion-reduce:animate-none">
  Bouncing content
</div>

// Or use the hook
"use client";
import { useMediaQuery } from "@/lib/hooks/use-media-query";

export function AnimatedComponent() {
  const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");

  return (
    <div style={{ transition: prefersReducedMotion ? "none" : "transform 0.3s ease" }}>
      Content
    </div>
  );
}

FORMS_ACCESSIBILITY

LABELS

RULE: every form input must have an associated label. No exceptions.

// GOOD: visible label with htmlFor
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" />

// GOOD: wrapping label (implicit association)
<label>
  Email address
  <input type="email" name="email" />
</label>

// GOOD: visually hidden label (when design requires it)
<label htmlFor="search" className="sr-only">Search products</label>
<input id="search" type="search" placeholder="Search products..." />

// BAD: no label at all
<input type="email" placeholder="Email" />

ANTI_PATTERN: using placeholder as the label. Placeholder disappears on focus and has poor contrast.

ERROR_MESSAGES

<div>
  <label htmlFor="email">Email</label>
  <input
    id="email"
    type="email"
    aria-invalid={!!errors.email}
    aria-describedby={errors.email ? "email-error" : "email-hint"}
  />
  <p id="email-hint" className="text-sm text-muted-foreground">
    We will never share your email.
  </p>
  {errors.email && (
    <p id="email-error" role="alert" className="text-sm text-destructive">
      {errors.email.message}
    </p>
  )}
</div>

FORM_GROUPS

// Related inputs grouped with fieldset/legend
<fieldset>
  <legend>Shipping Address</legend>
  <label htmlFor="street">Street</label>
  <input id="street" name="street" />
  <label htmlFor="city">City</label>
  <input id="city" name="city" />
</fieldset>

SCREEN_READER_TESTING

TOOLS

PRIMARY: NVDA (Windows, free), VoiceOver (macOS/iOS, built-in), TalkBack (Android, built-in)
AUTOMATED: axe-core (catches ~57% of WCAG issues), eslint-plugin-jsx-a11y (catches common JSX issues)
MANUAL: Accessibility Insights for Web (Chrome extension, guided assessment)

AXE_CORE_IN_TESTS

// Playwright + axe-core integration
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("homepage has no accessibility violations", async ({ page }) => {
  await page.goto("/");

  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();

  expect(results.violations).toEqual([]);
});

test("dashboard form is accessible", async ({ page }) => {
  await page.goto("/dashboard/settings");

  const results = await new AxeBuilder({ page })
    .include("#settings-form")
    .withTags(["wcag2a", "wcag2aa"])
    .analyze();

  expect(results.violations).toEqual([]);
});

ESLINT_JSX_A11Y

// .eslintrc.json
{
  "extends": ["plugin:jsx-a11y/recommended"],
  "rules": {
    "jsx-a11y/anchor-is-valid": "error",
    "jsx-a11y/click-events-have-key-events": "error",
    "jsx-a11y/no-static-element-interactions": "error",
    "jsx-a11y/img-redundant-alt": "error",
    "jsx-a11y/label-has-associated-control": "error"
  }
}

STORYBOOK_A11Y

// Component story with accessibility checks
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";

const meta: Meta<typeof Button> = {
  component: Button,
  parameters: {
    a11y: {
      config: {
        rules: [{ id: "color-contrast", enabled: true }],
      },
    },
  },
};

export default meta;

export const Primary: StoryObj<typeof Button> = {
  args: { children: "Click me", variant: "default" },
};

IMAGES_AND_MEDIA

ALT_TEXT

RULE: every <img> must have an alt attribute.
IF image is informative THEN provide descriptive alt text.
IF image is decorative THEN use alt="" (empty string, not omitted).
IF image is complex (chart, diagram) THEN provide extended description.

// Informative image
<Image src="/product.webp" alt="Red leather handbag with brass buckle" width={400} height={300} />

// Decorative image
<Image src="/divider.svg" alt="" width={800} height={2} aria-hidden="true" />

// Complex image with extended description
<figure>
  <Image src="/revenue-chart.png" alt="Revenue chart showing 25% growth in Q4" width={600} height={400} />
  <figcaption>
    Revenue grew from EUR 1.2M in Q3 to EUR 1.5M in Q4 2025, driven by
    new client onboarding in the Netherlands and Germany.
  </figcaption>
</figure>

ANTI_PATTERN: alt text starting with "image of" or "picture of" — screen readers already announce it as an image.


TESTING_CHECKLIST

AUTOMATED (CI pipeline):
- [ ] axe-core Playwright tests pass on all pages
- [ ] eslint-plugin-jsx-a11y reports zero errors
- [ ] Lighthouse accessibility score >= 95

MANUAL (per release):
- [ ] keyboard-only navigation works for all workflows
- [ ] screen reader (NVDA or VoiceOver) announces content correctly
- [ ] focus order is logical
- [ ] skip link works
- [ ] color contrast meets AA ratios
- [ ] content readable at 200% zoom
- [ ] reduced motion respected


SELF_CHECK

BEFORE_EVERY_COMPONENT:
- [ ] am I using semantic HTML before ARIA?
- [ ] does every interactive element work with keyboard?
- [ ] does every form input have a label?
- [ ] are error messages associated with inputs via aria-describedby?
- [ ] is color not the sole indicator of state?
- [ ] do images have appropriate alt text?
- [ ] are animations respecting prefers-reduced-motion?
- [ ] is focus visible for keyboard users?
- [ ] does the component pass axe-core checks?