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:

HTML
<!-- 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.
HTML
<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 (via element.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:

HTML
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main-content">...</main>
Css
.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:

HTML
<div role="status" aria-live="polite" id="save-status"></div>
Javascript
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:

HTML
<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>
Css
.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;
}
Javascript
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 add tabindex="0" and manually handle Enter/Space key 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.