Accessibility In Depth
ARIA roles and attributes, focus management, and a complete accessible modal dialog and dropdown menu example.
Beyond semantic elements
The semantic HTML page in this track covered the big win of accessibility: using <nav>, <main>, and heading levels so a screen reader can build a landmark outline for free. That covers content structure. It doesn't cover custom interactive widgets — a dropdown menu, a modal dialog, a set of tabs — that have no single native HTML element to fall back on. Building those accessibly means two more things: using ARIA attributes to describe state and role to assistive technology, and managing keyboard focus explicitly, since the browser has no built-in idea of how your custom widget should behave.
Accessible names: aria-label, aria-labelledby, aria-describedby
Every interactive element needs an accessible name — the string a screen reader announces for it. A <button> with visible text already has one for free. An icon-only button doesn't:
<!-- No accessible name — a screen reader announces just "button" -->
<button><svg aria-hidden="true">...</svg></button>
<!-- Fixed with aria-label -->
<button aria-label="Close dialog"><svg aria-hidden="true">...</svg></button>
| Attribute | Use when |
|---|---|
aria-label |
There's no visible text at all, and you need to supply one directly as a string. |
aria-labelledby |
Visible text that names the element already exists elsewhere on the page — point to its id instead of duplicating the string. |
aria-describedby |
You need to attach additional explanatory text (not the name itself) — like a hint or error message associated with a field. |
<h2 id="billing-heading">Billing address</h2>
<section aria-labelledby="billing-heading">
...
</section>
<label for="password">Password</label>
<input type="password" id="password" aria-describedby="password-hint">
<p id="password-hint">Must be at least 12 characters.</p>
aria-hidden="true" on the decorative <svg> in the button example above tells assistive technology to skip it entirely — it exists purely as a visual icon, and the real accessible name comes from aria-label on the button itself.
Focus management
Keyboard users navigate by moving focus with Tab/Shift+Tab, and activate whatever currently has it with Enter/Space. Three things make or break this for a custom widget:
tabindex="0"— inserts an element into the natural tab order, at the position it appears in the document. Needed only when you're forced to make a non-interactive element (like a<div>) focusable — a native<button>or<a>never needs it.tabindex="-1"— makes an element focusable only programmatically (viaelement.focus()in JavaScript), not by tabbing. This is exactly what's needed for a modal's heading or container: it should receive focus the instant the modal opens, but shouldn't sit in the page's normal tab order.- Never use a positive
tabindex(tabindex="5") — it forces that element ahead of the document's natural order, producing a tab sequence that no longer matches the visual layout, which is disorienting for anyone navigating by keyboard.
A skip link is the simplest high-value focus-management pattern on any page — a hidden link, revealed only on focus, that jumps a keyboard user straight past a repeated navbar:
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main-content">...</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #111827;
color: white;
padding: 0.5rem 1rem;
}
.skip-link:focus {
top: 0; /* slides into view only once it receives keyboard focus */
}
Live regions: aria-live
An aria-live region announces content that changes without the user's focus moving there — a form validation message, a "saved" confirmation, a live search result count:
<div role="status" aria-live="polite" id="save-status"></div>
document.getElementById('save-status').textContent = 'Changes saved.';
aria-live="polite"— announced after the screen reader finishes whatever it's currently saying. Right for most status updates.aria-live="assertive"— interrupts immediately. Reserve it for something genuinely urgent, like a session-expiry warning.
A complete example: an accessible modal dialog
This combines everything above — an accessible name, a role, and a full keyboard focus trap that returns focus to whatever opened it:
<button id="delete-trigger" type="button">Delete account</button>
<div id="delete-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-title" hidden>
<div class="modal-content">
<h2 id="delete-title">Delete your account?</h2>
<p>This action is permanent and can't be undone.</p>
<div class="modal-actions">
<button type="button" data-close>Cancel</button>
<button type="button" class="danger">Delete</button>
</div>
</div>
</div>
.modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.modal[hidden] {
display: none;
}
.modal-content {
background: white;
padding: 1.5rem;
border-radius: 8px;
max-width: 24rem;
}
const trigger = document.getElementById('delete-trigger');
const modal = document.getElementById('delete-modal');
let lastFocused = null;
function openModal() {
lastFocused = document.activeElement;
modal.hidden = false;
modal.querySelector('button').focus();
document.addEventListener('keydown', onKeydown);
}
function closeModal() {
modal.hidden = true;
document.removeEventListener('keydown', onKeydown);
if (lastFocused) lastFocused.focus(); // return focus to whatever opened the modal
}
function onKeydown(event) {
if (event.key === 'Escape') {
closeModal();
return;
}
if (event.key === 'Tab') {
const focusable = modal.querySelectorAll('button');
const first = focusable[0];
const last = focusable[focusable.length - 1];
// Trap Tab/Shift+Tab so focus can never escape the open modal
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
trigger.addEventListener('click', openModal);
modal.querySelectorAll('[data-close]').forEach(btn => btn.addEventListener('click', closeModal));
Three details make this actually accessible, not just visually convincing: aria-modal="true" plus role="dialog" tell assistive technology this is a modal region, focus moves into the modal the moment it opens rather than staying on a now-hidden trigger, and focus is explicitly restored to the trigger button on close — without that last step, a keyboard user closing the modal would land back at the very top of the document instead of where they were.
Common mistakes
- Adding
role="button"and a click handler to a<div>instead of using a real<button>— you'd still need to addtabindex="0"and manually handleEnter/Spacekey activation, and it's easy to miss an edge case a native element already handles for free. - Building a custom dropdown or modal with visual styling but no keyboard support at all — it works for a mouse user and is completely unusable for a keyboard-only or screen-reader user.
- Using
aria-live="assertive"for routine status messages — it interrupts whatever the screen reader is currently announcing, which is jarring when used for anything less urgent than an error or a session timeout. - Forgetting to restore focus to the triggering element after closing a modal or dismissing a dropdown — the keyboard focus is left on (or inside) an element that no longer exists or is now hidden, effectively losing the user's place in the page.