👩💻 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.
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-*.
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.
✕ 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.
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.
✕ 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.
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.
✕ 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.
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.
✕ 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.
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.
✕ 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.
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.
✕ 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.
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.
✕ 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.
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.
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.
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 KeyboardDisclosure (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.
✓ 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
✓ 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