---
name: forge-accessibility
description: Web accessibility (a11y) discipline. Semantic HTML over div soup, ARIA only when semantic markup is insufficient, color contrast ≥4.5:1, focus order matches reading order, keyboard navigation, screen-reader-friendly forms, alt text discipline. Contains paste-ready component patterns, contrast tokens, and a focus-ring recipe. Use whenever building UI that users will navigate with a keyboard or screen reader.
license: MIT
---

# forge-accessibility

You are writing web UI that some users will navigate with a keyboard, some with a screen reader, some with low vision, some with cognitive load. Default agent-written components are `<div onClick>` everywhere, no `alt`, focus outlines removed for aesthetic reasons, color contrast below WCAG. This skill exists to fix that without turning every component into ARIA soup.

The mental model: **semantic HTML is your first accessibility layer.** ARIA is a patch when semantic HTML cannot express what you need. Most accessibility wins come from not breaking the defaults.

## Quick reference (the things you must never ship)

1. `<div onClick={...}>` for something that should be a `<button>`.
2. `outline: none` on `:focus` without a replacement focus ring.
3. `<img>` without `alt` (use `alt=""` for decorative).
4. `color` and `bg` pair with contrast < 4.5:1 for normal text (< 3:1 for large).
5. `<input>` without an associated `<label>`.
6. `placeholder=""` as the only label.
7. Removing the focus ring on a custom-styled button.
8. Icon-only button without `aria-label`.
9. Modal that does not trap focus.
10. `<a href="javascript:void(0)" onClick>` instead of `<button>`.

## Hard rules

### Semantic HTML first

**1. Use the right element for the job.**

| Element | When |
| --- | --- |
| `<button>` | Anything that triggers an action in the page |
| `<a href>` | Anything that navigates to a URL (including a different page state) |
| `<input>`, `<select>`, `<textarea>` | Form fields - never re-implement |
| `<nav>`, `<main>`, `<aside>`, `<header>`, `<footer>` | Page landmarks |
| `<h1>` → `<h6>` | Heading hierarchy, no level skips |
| `<ul>`, `<ol>`, `<li>` | Lists - including a "nav menu" |
| `<dialog>` | Modals (preferred over `<div role="dialog">` if your browser support allows) |
| `<details>`, `<summary>` | Disclosures (accordions) |
| `<table>`, `<thead>`, `<tbody>`, `<th scope=...>` | Tabular data (NOT for layout) |

```tsx
// BAD: div soup
<div onClick={handleClick}>Save</div>
<div className="modal">...</div>
<div className="link" onClick={() => navigate('/x')}>Open X</div>

// GOOD: semantic
<button type="button" onClick={handleClick}>Save</button>
<dialog ref={dialogRef} className="modal">...</dialog>
<a href="/x">Open X</a>
```

**2. ARIA only when semantic HTML cannot express what you need.** The first rule of ARIA is: don't use ARIA if HTML works.

**3. No `role="button"` on a `<div>`.** Use `<button>`. You will get focus, keyboard, screen-reader announcement, all for free.

### Keyboard

**4. Every interactive element is focusable.** `<button>`, `<a href>`, `<input>` are by default. Custom widgets need `tabindex="0"`.

**5. Focus ring visible. Always.** If you remove the default outline, replace it with something equally clear.

```css
/* canonical focus ring recipe */
*:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}

/* on a dark background */
*:focus-visible {
  outline: 2px solid #FF7A45;
  outline-offset: 2px;
}
```

Use `:focus-visible` (not `:focus`) so the ring only appears for keyboard users, not mouse clicks.

**6. Focus order matches visual order.** Tab through your page; the order should match the reading order. Reorderings (flexbox `order`, absolute positioning) can desynchronize them.

**7. Escape closes modals. Tab traps inside modals.**

```tsx
// reference: focus trap for a custom modal
useEffect(() => {
  if (!isOpen) return;
  const previousFocus = document.activeElement as HTMLElement | null;
  modalRef.current?.focus();

  function onKey(e: KeyboardEvent) {
    if (e.key === "Escape") onClose();
    if (e.key === "Tab" && modalRef.current) {
      // trap focus inside the modal
      const focusable = modalRef.current.querySelectorAll<HTMLElement>(
        'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
      );
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last?.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first?.focus();
      }
    }
  }
  document.addEventListener("keydown", onKey);
  return () => {
    document.removeEventListener("keydown", onKey);
    previousFocus?.focus();
  };
}, [isOpen, onClose]);
```

Native `<dialog>` handles this for you.

### Labels

**8. Every form input has a visible label.** `<label htmlFor="email">Email</label> <input id="email">` OR wrap: `<label>Email <input /></label>`.

**9. `placeholder` is a hint, NOT a label.** Placeholders disappear on focus and have poor contrast.

```tsx
// BAD
<input placeholder="Email" />

// GOOD
<label htmlFor="email">Email</label>
<input id="email" type="email" placeholder="anna@example.com" />
```

**10. Icon-only buttons need `aria-label`.**

```tsx
// BAD
<button onClick={onClose}>×</button>

// GOOD
<button onClick={onClose} aria-label="Close dialog">×</button>
```

### Images

**11. Every `<img>` has `alt`.** Use `alt=""` for purely decorative images (so screen readers skip them entirely).

```tsx
// purely decorative - skipped by screen readers
<img src="/divider.svg" alt="" />

// content image - describes what it is
<img src={user.avatarUrl} alt={`${user.name}'s avatar`} />

// chart - describes what it conveys
<img src="/chart.png" alt="Revenue grew 23% from Q1 to Q2." />
```

**12. Inline SVG icons get `aria-hidden="true"` if accompanied by text.** Otherwise `<title>` or `aria-label`.

```tsx
// icon next to text - hide from AT, label comes from the text
<button>
  <CheckIcon aria-hidden="true" /> Save
</button>

// icon-only - provide a label
<button aria-label="Open menu">
  <MenuIcon aria-hidden="true" />
</button>
```

### Color and contrast

**13. Contrast ratio ≥4.5:1 for normal text, ≥3:1 for large (18pt+ / 14pt+ bold).** [WCAG 1.4.3].

```css
/* check contrast at https://webaim.org/resources/contrastchecker/ */

/* GOOD examples (≥4.5:1) */
color: #1A1A1A on background #FAFAFA   /* 17.4:1 */
color: #4A453E on background #F5F2EC   /* 8.9:1  */
color: #FFFFFF on background #C8501E   /* 4.8:1  */

/* BAD - common defaults that fail */
color: #999999 on background #FFFFFF   /* 2.85:1 FAIL */
color: #6B7280 on background #F9FAFB   /* 4.3:1  FAIL for normal text */
```

**14. Do not use color alone to convey state.** Red ✗ vs green ✓ also need an icon or text.

```tsx
// BAD: red only
<span style={{ color: 'red' }}>Failed</span>

// GOOD: color + icon + text
<span className="text-red-700">
  <AlertIcon aria-hidden="true" /> Failed
</span>
```

### Forms

**15. Required fields marked clearly.** `<input required>` + visible asterisk + an aria-described error message.

**16. Error messages associated with the input.** `aria-describedby` ties an error to its input.

```tsx
<label htmlFor="email">Email</label>
<input
  id="email"
  type="email"
  required
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
  <p id="email-error" role="alert" className="text-red-700 text-sm">
    {errors.email}
  </p>
)}
```

**17. Submit on Enter inside a form. Default `<button type="submit">`.** Explicit `type="button"` for non-submit buttons inside a form.

### Headings

**18. One `<h1>` per page.** Hierarchy is `h1` → `h2` → `h3`; do not skip levels.

**19. Headings outline the page.** A screen-reader user navigates by heading. If your nav menu is a sequence of `<h2>` items, the outline is broken.

### Live regions

**20. Toast notifications use `role="status"` (polite) or `role="alert"` (assertive).**

```tsx
// non-urgent: toast appears, screen reader announces when convenient
<div role="status" aria-live="polite">{toastMessage}</div>

// urgent: interrupts current screen-reader speech
<div role="alert">{errorMessage}</div>
```

### Skipped content

**21. "Skip to main content" link at the top of the page.** Keyboard users tab through nav repeatedly without one.

```tsx
<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:bg-zinc-900 focus:text-white focus:p-2">
  Skip to main content
</a>
```

### Motion

**22. Respect `prefers-reduced-motion`.**

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
```

## Common AI-output patterns to reject

| Pattern | Why bad | Fix |
| --- | --- | --- |
| `<div onClick={...}>` | Not focusable, no keyboard | `<button>` |
| `outline: none` on focus | Keyboard-invisible | Custom focus ring with `:focus-visible` |
| `placeholder="Email"` no label | Disappears on focus, poor AT | `<label>` |
| `<a href="#" onClick={...}>` | Not a navigation | `<button>` |
| `<a href="javascript:void(0)">` | Same | `<button>` |
| Icon-only button no `aria-label` | Screen reader hears nothing | `aria-label="Close dialog"` |
| `<img>` no `alt` | Skipped or "image" announced | `alt="..."` or `alt=""` |
| Color-only state (red ✗ vs green ✓) | Colorblind invisible | Icon + text + color |
| Modal no focus trap | Tab escapes the modal | Trap + Escape closes + return focus |
| Nav menu of `<h2>` items | Breaks heading outline | `<nav><ul><li><a>` |
| Removed focus on custom button | Keyboard users lost | Custom `:focus-visible` ring |
| `color: gray-500 on white` | <4.5:1 contrast | `zinc-700` or darker |

## Worked example: accessible button + dialog

```tsx
// AccessibleDialog.tsx
import { useEffect, useRef } from "react";

type Props = {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
};

export function AccessibleDialog({ isOpen, onClose, title, children }: Props) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    if (isOpen) {
      dialogRef.current?.showModal();   // native focus trap + Escape
    } else {
      dialogRef.current?.close();
    }
  }, [isOpen]);

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby="dialog-title"
      onClose={onClose}
      className="rounded-xl border border-zinc-200 p-8 backdrop:bg-zinc-900/40"
    >
      <h2 id="dialog-title" className="text-2xl font-medium mb-4">{title}</h2>
      <div>{children}</div>
      <div className="mt-6 flex gap-2 justify-end">
        <button
          type="button"
          onClick={onClose}
          className="border border-zinc-300 px-4 py-2 rounded-md
                     hover:border-zinc-900
                     focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900"
        >
          Cancel
        </button>
        <button
          type="submit"
          className="bg-zinc-900 text-white px-4 py-2 rounded-md
                     hover:bg-zinc-700
                     focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900"
        >
          Confirm
        </button>
      </div>
    </dialog>
  );
}
```

What this does right: native `<dialog>` (focus trap + Escape free); `aria-labelledby` ties the dialog to its title; explicit `type="button"` on the cancel button (rule 17); focus-visible ring on both buttons (rule 5); contrast: zinc-900 on white is 17:1 (rule 13).

## Workflow

When building UI:

1. **Choose the right semantic element first.** `<button>` not `<div onClick>`.
2. **Tab through the page. Check focus order matches reading order.**
3. **Check contrast with a tool** (WebAIM contrast checker, browser DevTools Accessibility panel).
4. **Test with VoiceOver / NVDA on at least one component.** You will find issues impossible to see visually.
5. **Run `axe` or Lighthouse a11y audit on every page.** Fix everything `axe` flags.

## Verification

Manual checklist:

- [ ] Every interactive element is `<button>`, `<a>`, `<input>`, etc. - not a styled `<div>`.
- [ ] `:focus-visible` ring on every interactive element.
- [ ] Every `<img>` has `alt` (`""` for decorative).
- [ ] Every form `<input>` has a `<label>`.
- [ ] Color contrast ≥4.5:1 for normal text.
- [ ] No color-only state indicators.
- [ ] Modals trap focus, restore focus on close, close on Escape.
- [ ] One `<h1>` per page, no skipped heading levels.
- [ ] `prefers-reduced-motion` honored.

## When to skip this skill

- Internal tools used by 5 people who all have the same context.
- Pure backend code with no UI surface.
- Prototypes / one-off demos where audience is known to be fully able.

## Related skills

- [`forge-frontend`](../forge-frontend/SKILL.md) - the taste rules on the same surface.
- [`forge-soft`](../forge-soft/SKILL.md) / [`forge-minimalist`](../forge-minimalist/SKILL.md) / [`forge-brutalist`](../forge-brutalist/SKILL.md) - registers must respect contrast minimums.
