I'll be honest—I used to treat accessibility as a checkbox. Something to verify before launch, usually rushed and half-done. Then I watched a user test where someone navigated our app with a screen reader. Watching them get stuck in our "beautiful" custom modal, unable to escape, Tab key cycling endlessly through invisible elements behind it—that changed how I think about this stuff.

The irony is that the web is accessible by default. HTML was designed to work with screen readers and keyboards. It's usually our fancy JavaScript widgets that break everything.

This guide is about fixing that. I'll show you how to make custom components (modals, dropdowns, accordions) actually work for everyone—not just people with mice.

Why "Just Use Semantic HTML" Isn't the Full Story

You've probably heard the advice: use a <button> instead of a <div>. And yes, absolutely do that. A button gives you a lot for free:

But here's the thing—semantic HTML only covers static content. The moment you build something dynamic (a dropdown that opens, a modal that appears, an accordion that expands), you need JavaScript to communicate those state changes to assistive technology. The screen reader can't "see" that your dropdown opened. You have to tell it.

ARIA: Telling Screen Readers What Changed

ARIA attributes are how we bridge visual changes with screen reader announcements. Let me show you with a collapsible section.

Visually, when a user clicks "Show More," they see content appear and maybe an arrow flip. A blind user sees nothing—unless your code tells the screen reader what happened.

<!-- The markup -->
<button id="toggle-btn" aria-expanded="false" aria-controls="content-panel">
    Show More
</button>
<div id="content-panel" hidden>
    Hidden content here...
</div>
// The JavaScript
const btn = document.getElementById('toggle-btn');
const content = document.getElementById('content-panel');

btn.addEventListener('click', () => {
    const isHidden = content.hasAttribute('hidden');
    
    if (isHidden) {
        content.removeAttribute('hidden');
        btn.setAttribute('aria-expanded', 'true');
        btn.textContent = "Show Less";
    } else {
        content.setAttribute('hidden', '');
        btn.setAttribute('aria-expanded', 'false');
        btn.textContent = "Show More";
    }
});

That aria-expanded attribute is doing the heavy lifting. When it flips from "false" to "true," the screen reader immediately announces "Expanded." Without it, the user presses the button and has no idea if anything happened.

The Modal Focus Trap

Modals are where I see the most accessibility bugs. The common mistake is just showing a div on top of the page and calling it a day.

Problem: when a keyboard user presses Tab, their focus drifts behind the modal. They're clicking links and buttons they can't see. I've seen users accidentally submit forms while trying to close a modal.

A proper modal needs to do three things:

  1. Move focus into the modal when it opens
  2. Keep focus trapped inside (Tab loops from last element back to first)
  3. Return focus to the trigger button when it closes

Here's a working implementation:

const modal = document.getElementById('my-modal');
const openBtn = document.getElementById('open-modal-btn');
const closeBtn = document.getElementById('close-modal-btn');

let previousActiveElement;

function openModal() {
    // Remember where focus was
    previousActiveElement = document.activeElement;
    
    modal.removeAttribute('hidden');
    modal.setAttribute('aria-modal', 'true');
    
    // Move focus into the modal
    closeBtn.focus();
    
    // Start trapping focus
    modal.addEventListener('keydown', handleKeydown);
}

function closeModal() {
    modal.setAttribute('hidden', '');
    modal.removeEventListener('keydown', handleKeydown);
    
    // Put focus back where it was
    if (previousActiveElement) {
        previousActiveElement.focus();
    }
}

function handleKeydown(e) {
    if (e.key === 'Escape') {
        closeModal();
        return;
    }
    
    if (e.key !== 'Tab') return;
    
    const focusable = modal.querySelectorAll(
        'button, [href], input, select, textarea, [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();
    }
}

That previousActiveElement bit is crucial. Without it, closing the modal dumps the user at the top of the page, losing their place.

Keyboard Patterns for Custom Widgets

If you're building something like a custom dropdown with divs (I'm not judging, sometimes you have to), you need to handle more than just clicks:

customSelect.addEventListener('keydown', (e) => {
    switch(e.key) {
        case 'Enter':
        case ' ':
            e.preventDefault();
            isOpen ? selectCurrentOption() : openMenu();
            break;
        case 'ArrowDown':
            e.preventDefault();
            highlightNext();
            break;
        case 'ArrowUp':
            e.preventDefault();
            highlightPrevious();
            break;
        case 'Escape':
            closeMenu();
            break;
    }
});

Yes, this is more code than just handling clicks. That's the tradeoff. But it means power users (and anyone who can't use a mouse) can actually use your widget.

Mistakes I've Made (So You Don't Have To)

Setting tabindex to positive numbers. Don't do tabindex="1" or tabindex="5". It forces an unnatural tab order that confuses everyone. Use 0 to make something focusable, or -1 if you only want to focus it programmatically.

Removing focus outlines. I get it, the default blue ring is ugly. But *:focus { outline: none; } makes your site unusable for keyboard users. Replace the outline with something that fits your design—just don't remove it entirely.

The div-as-button trap. If you use <div onclick="...">, you need to add role="button", tabindex="0", and handle Enter and Space key events. At that point, you've written more code than just using a <button> and styling it with CSS.

The Bigger Picture

Accessibility isn't charity work. It's not "extra." It's just building things correctly.

The web was designed to be accessible. When you build a custom widget and it doesn't work with a keyboard, you've broken something that was working fine before you touched it. Our job is to enhance the web, not make it worse.

Start with semantic HTML. When you need JavaScript, update your ARIA attributes. Manage focus properly. Handle keyboard events. It's more code, but it's code that makes your work usable by everyone.

← Back to JavaScript Articles

Back to Home