👩‍💻 Accessibility for developers

Build it right in the markup

Most accessibility wins are decided in the HTML — before any ARIA, before any JavaScript. Below is the same UI written two ways, inaccessible and accessible, with copy-paste-ready code. Automated tools catch only about a third of issues, so treat these as the patterns to reach for, then test with the keyboard and a screen reader.

✕ Avoid Fragile markup ✓ Better Accessible by default

Golden rule

The first rule of ARIA

“If you can use a native HTML element or attribute with the semantics and behaviour you need already built in, then do so.” Native elements are focusable, keyboard-operable and announced correctly for free — and no ARIA is better than bad ARIA. Reach for <button>, <a>, <label> and <dialog> before role= and aria-*.

01

Start with semantic HTML & landmarks

Landmark elements let screen-reader users jump straight to the nav, the main content or the footer. A page of <div>s gives them nothing to navigate by.

WCAG 1.3.1 Info & Relationships · 2.4.1 Bypass Blocks

✕ Avoid

<div class="header">…</div>
<div class="nav">…</div>
<div class="main">
  <div class="title">Our services</div>
</div>
<div class="footer">…</div>

Every region is an anonymous <div>. There are no landmarks, no headings, and nothing to skip to.

✓ Better

<header>…</header>
<nav aria-label="Primary">…</nav>
<main id="main">
  <h1>Our services</h1>
</main>
<footer>…</footer>

Native landmarks (header, nav, main, footer) and a real <h1> map the page for assistive tech automatically.

02

Use real buttons and links

A link goes somewhere; a button does something. A clickable <div> is neither — it can't be reached or fired by keyboard.

WCAG 2.1.1 Keyboard · 4.1.2 Name, Role, Value

✕ Avoid

<div class="btn" onclick="save()">
  Save
</div>

<a href="#" onclick="openMenu()">Menu</a>

The <div> isn't focusable and ignores Enter/Space. The href="#" link scrolls the page and lies about being a navigation.

✓ Better

<button type="button" onclick="save()">
  Save
</button>

<!-- real navigation -->
<a href="/cart">Cart</a>

A <button> is focusable, keyboard-operable and announced as a button. Use <a href> only when you're actually navigating.

03

Give every control an accessible name

An icon-only control with no text is announced as just “button” or “link”. Add a name with visible text, a visually-hidden label, or aria-label.

WCAG 4.1.2 Name, Role, Value · 1.1.1 Non-text Content

✕ Avoid

<button>🔍</button>
<a href="/home">
  <svg>…</svg>
</a>

The emoji and SVG are invisible to the accessibility tree, so each control has no name to announce.

✓ Better

<button aria-label="Search">🔍</button>

<!-- or visible, hidden text -->
<a href="/home">
  <span class="sr-only">Home</span>
  <svg aria-hidden="true">…</svg>
</a>

Precedence: aria-labelledby > aria-label > text content > title. Hide decorative icons with aria-hidden="true".

The .sr-only utility

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap; border: 0;
}

Visually hidden, still read aloud. Prefer this over display:none or visibility:hidden, which remove content from the accessibility tree too.

04

Label fields & wire up errors

A placeholder isn't a label — it vanishes on typing and is often too faint. Tie each field to a real <label>, and connect errors with aria-describedby.

WCAG 1.3.1 · 3.3.1 Error Identification · 3.3.2 Labels or Instructions

✕ Avoid

<input type="email"
       placeholder="Email">
<span style="color:red">✗</span>

No label to announce, and the error is colour-only with no text — invisible to screen readers and to colour-blind users.

✓ Better

<label for="email">Email</label>
<input type="email" id="email"
       autocomplete="email"
       aria-describedby="email-err"
       aria-invalid="true">
<span id="email-err">
  Enter a valid email address.
</span>

The for/id pair names the field; aria-describedby reads the error after the label; aria-invalid marks the state. Errors use text, not colour alone.

05

Write alt attributes with intent

Missing alt makes a screen reader read the filename. Meaningful images need a description; purely decorative ones need an empty alt so they're skipped.

WCAG 1.1.1 Non-text Content

✕ Avoid

<img src="chart.png">

<img src="swoosh.svg"
     alt="decorative swoosh image">

No alt falls back to “chart.png”. Describing a purely decorative flourish just adds noise.

✓ Better

<img src="chart.png"
     alt="Sales doubled from Jan to June 2024">

<!-- decorative: empty alt, not missing -->
<img src="swoosh.svg" alt="">

Describe the image's purpose. For charts, summarise the takeaway and put the full data in nearby text or a table.

06

Never remove the focus outline

Keyboard users track their position by the focus ring. Removing it strands them. Style it instead — and use :focus-visible so it shows for keyboards, not mouse clicks.

WCAG 2.4.7 Focus Visible (AA) · 2.4.13 Focus Appearance (AAA)

✕ Avoid

*:focus {
  outline: none;
}

This hides focus everywhere, for everyone. It's one of the most common — and most damaging — accessibility mistakes.

✓ Better

:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 2px;
  border-radius: 4px;
}

A high-contrast ring (≥ 3:1 against its background), only when navigating by keyboard. Give it an offset so it isn't clipped by the element.

07

Use the native <dialog>

A real modal traps focus, closes on Esc, and returns focus to the trigger. The native element does all three for you — a hand-rolled <div> overlay does none.

WCAG 2.4.3 Focus Order · 2.1.2 No Keyboard Trap

✕ Avoid

<div class="overlay" id="m">…</div>

<script>
  open.onclick = () =>
    m.style.display = "block";
</script>

Focus stays on the page behind, Tab escapes the modal, Esc does nothing, and focus is lost on close.

✓ Better

<dialog id="m" aria-labelledby="t">
  <h2 id="t">Subscribe?</h2>
  <button value="close">Close</button>
</dialog>

<script>
  open.onclick = () => m.showModal();
</script>

showModal() traps focus, enables Esc, adds a backdrop and restores focus to the opener on close — no JavaScript focus-trap needed.

08

Announce dynamic changes

When content updates without a page load — a “Saved” toast, a filtered result count — screen readers stay silent unless you put the message in a live region.

WCAG 4.1.3 Status Messages

✓ Pattern

<!-- prime an empty region in the DOM first -->
<div role="status" aria-live="polite"
     class="sr-only" id="status"></div>

<script>
  // later: just set its text
  status.textContent = "Draft saved";
</script>

aria-live="polite" (or role="status") waits for a pause; aria-live="assertive" / role="alert" interrupts — save it for genuine errors. The container must exist before you inject text.

Try it

Live region demo

Turn on a screen reader, then press the button. The count updates in a polite live region and is announced without moving your focus.

09

Skip links & reduced motion

Two small, high-impact touches: let keyboard users bypass the nav, and honour people who've asked their system for less motion.

WCAG 2.4.1 Bypass Blocks · 2.3.3 Animation from Interactions

✓ Skip link

<a href="#main" class="skip-link">
  Skip to main content
</a>
…
<main id="main">…</main>

/* visible only when focused */
.skip-link {
  position: absolute;
  transform: translateY(-160%);
}
.skip-link:focus { transform: none; }

The first focusable element, hidden until Tab reveals it, jumping past repeated navigation.

✓ Reduced motion

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

Respects the OS “reduce motion” setting, which people with vestibular disorders rely on. Keep essential animation, but drop the parallax and the big spins.

10

Accessible component patterns

Custom widgets are where accessibility gets hard. Don't guess the roles and keyboard behaviour — the ARIA Authoring Practices Guide has worked them out. Three you'll reach for often, live and with code:

WCAG 4.1.2 Name, Role, Value · 2.1.1 Keyboard

Disclosure (show / hide)

✓ Pattern

<button aria-expanded="false"
        aria-controls="panel">
  Show details
</button>
<div id="panel" hidden>…</div>

// toggle both together
btn.onclick = () => {
  var open = btn.getAttribute("aria-expanded") === "true";
  btn.setAttribute("aria-expanded", !open);
  panel.hidden = open;
};

A plain <button> whose aria-expanded mirrors whether the region is shown. No custom role needed.

Tabs

Click a tab, or focus one and use , Home and End.

Billed month to month. Cancel any time.

✓ Pattern

<div role="tablist" aria-label="Pricing">
  <button role="tab" id="t1"
          aria-selected="true"
          aria-controls="p1">Monthly</button>
  <button role="tab" id="t2"
          aria-selected="false"
          aria-controls="p2" tabindex="-1">Yearly</button>
</div>
<div role="tabpanel" id="p1"
     aria-labelledby="t1">…</div>
<div role="tabpanel" id="p2"
     aria-labelledby="t2" hidden>…</div>

Only the selected tab is in the tab order (tabindex="-1" on the rest); arrow keys move between them and update aria-selected and the panels.

Tooltip

Hover or focus the button; press Esc to dismiss it.

✓ Pattern

<button aria-describedby="tip">
  Response time
</button>
<span role="tooltip" id="tip" hidden>
  We reply within one business day.
</span>

// show on focus AND hover; hide on
// blur, mouseleave and Escape

aria-describedby ties the tip to the button so it's announced. Make it dismissible with Esc and reachable by keyboard, not hover-only.

Accordion

Yes — manual WCAG 2.2 audits with a prioritised report and re-test.

✓ Pattern

<h4>
  <button aria-expanded="true"
          aria-controls="p1" id="b1">
    Do you offer audits?
  </button>
</h4>
<div id="p1" role="region"
     aria-labelledby="b1">…</div>

Each item is a real heading + button (so heading navigation works), toggling its own panel. Several can be open at once.

Switch

✓ Pattern

<button role="switch"
        aria-checked="false">
  <span class="track" aria-hidden="true">…</span>
  Email notifications
</button>

// flip aria-checked on click
// (a <button> gives you Space/Enter free)

A <button> with role="switch" — announced as “switch, on/off”. The visible track is aria-hidden; the state lives in aria-checked.

Combobox (autocomplete)

Type to filter, then to move, Enter to choose, Esc to close.

✓ Pattern

<label for="cb">Choose an atoll</label>
<input id="cb" role="combobox"
       aria-expanded="false"
       aria-controls="list"
       aria-autocomplete="list"
       aria-activedescendant="">
<ul id="list" role="listbox" hidden>
  <li role="option" id="o1">Malé</li>
  …
</ul>

The input keeps focus; aria-activedescendant points at the highlighted option so arrow keys move a virtual cursor without leaving the field. aria-expanded tracks whether the list is open.

Menu button

Open with Enter or ; move with ; Esc closes and returns focus.

✓ Pattern

<button aria-haspopup="true"
        aria-expanded="false"
        aria-controls="menu">Actions</button>
<ul id="menu" role="menu" hidden>
  <li role="none">
    <button role="menuitem">Edit</button>
  </li>
  …
</ul>

Unlike the combobox, a menu moves real focus between menuitems. Opening moves focus to the first item; Esc and selecting both close it and return focus to the button.

Test what you build

Automated tools catch roughly a third of issues — a great first pass, never the whole story.

⌨️ Keyboard only

Unplug the mouse. Can you reach and operate everything with Tab, Enter, Space and arrows, and always see focus?

🔊 Screen reader

Run NVDA (Windows) or VoiceOver (Mac/iOS). Do controls announce a clear name and role? Do updates get spoken?

🧪 axe DevTools (opens in a new tab)

Scan the page for the issues automation can catch. Pair it with the manual checks above.

📐 ARIA Authoring Practices (opens in a new tab)

Vetted keyboard-and-ARIA patterns for tabs, menus, comboboxes and more. Copy the pattern, don't invent one.

Want a code-level accessibility audit of your app, or hands-on training for your dev team?

Work with me