Skip to content

Building Accessible Custom Components

intermediate22 min read

Why Native Elements Are Not Enough

You reach for a custom tab component. A fancy autocomplete. A carousel that marketing demanded. The moment you build these from div and span instead of native HTML elements, you inherit a responsibility: you must recreate every behavior that the browser gives for free.

That means keyboard navigation, focus management, screen reader announcements, and state communication. Skip any of these and your component works for mouse users with good vision — and nobody else. That is roughly 85% of your users on a good day.

Mental Model

Think of native HTML elements like select or button as pre-built accessibility contracts. The browser handles keyboard events, focus, ARIA roles, and state announcements automatically. When you build custom components from generic elements, you are signing a blank contract and writing every clause yourself. WAI-ARIA Authoring Practices is the legal template that tells you exactly what to write.

WAI-ARIA Authoring Practices: Your Reference

The WAI-ARIA Authoring Practices Guide (APG) is not a suggestion — it is the reference for building accessible custom widgets. It defines expected keyboard interactions, ARIA roles, states, and properties for every common pattern.

Here is how to use it:

  1. Find the pattern — Look up the widget you are building (tabs, combobox, accordion, etc.)
  2. Read the keyboard interaction — This defines exactly which keys do what
  3. Apply roles, states, properties — These tell assistive technology what the widget is and what state it is in
  4. Test with a screen reader — The spec is necessary but not sufficient. Real testing catches what specs miss.
Key Rules
  1. 1Always start from WAI-ARIA Authoring Practices patterns before inventing your own interaction model
  2. 2Every custom interactive widget needs explicit roles, keyboard handling, and ARIA states
  3. 3ARIA is a last resort — if a native HTML element does the job, use it instead
  4. 4role, tabindex, and aria-* attributes do not add behavior — you must implement keyboard handling yourself
  5. 5Test with at least two screen readers (VoiceOver + NVDA) because they interpret ARIA differently

Building Accessible Tabs

Tabs are one of the most common custom widgets, and one of the most frequently botched. Here is what the APG requires.

The ARIA Contract

<div role="tablist" aria-label="Project settings">
  <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1" tabindex="0">
    General
  </button>
  <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">
    Members
  </button>
  <button role="tab" id="tab-3" aria-selected="false" aria-controls="panel-3" tabindex="-1">
    Billing
  </button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
  <!-- General settings content -->
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabindex="0" hidden>
  <!-- Members content -->
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" tabindex="0" hidden>
  <!-- Billing content -->
</div>

Let's break down what each piece does:

  • role="tablist" tells assistive tech this is a group of tabs (announced as "tab list, 3 tabs")
  • role="tab" on each button identifies it as a tab (announced as "General, tab, 1 of 3")
  • aria-selected="true" communicates which tab is active
  • aria-controls creates a programmatic link between the tab and its panel
  • role="tabpanel" identifies the content area
  • aria-labelledby gives the panel an accessible name (the tab's text)
  • tabindex="-1" on inactive tabs removes them from the tab order. Only the active tab is in the tab order.

Keyboard Interactions

This is where most implementations fail. The APG defines a roving tabindex pattern for tabs:

function handleTabKeyDown(event, tabs, panels) {
  const currentIndex = tabs.indexOf(event.target);
  let newIndex;

  switch (event.key) {
    case 'ArrowRight':
      newIndex = (currentIndex + 1) % tabs.length;
      break;
    case 'ArrowLeft':
      newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
      break;
    case 'Home':
      newIndex = 0;
      break;
    case 'End':
      newIndex = tabs.length - 1;
      break;
    default:
      return;
  }

  event.preventDefault();

  tabs[currentIndex].setAttribute('tabindex', '-1');
  tabs[currentIndex].setAttribute('aria-selected', 'false');

  tabs[newIndex].setAttribute('tabindex', '0');
  tabs[newIndex].setAttribute('aria-selected', 'true');
  tabs[newIndex].focus();

  panels.forEach((panel, i) => {
    panel.hidden = i !== newIndex;
  });
}

The key behaviors:

  • Arrow Left/Right moves between tabs (wrapping at edges)
  • Home/End jumps to first/last tab
  • Tab key moves focus into the active panel, not to the next tab
  • Only the active tab has tabindex="0" — this is the roving tabindex pattern
Roving tabindex vs aria-activedescendant

There are two patterns for managing focus within composite widgets. Roving tabindex (used in tabs) moves actual DOM focus between elements by toggling tabindex="0" and tabindex="-1". aria-activedescendant keeps focus on a container element and uses the attribute to tell assistive tech which child is "active." Roving tabindex has better screen reader support and is recommended for tabs. aria-activedescendant works better for widgets where you need to keep focus on an input while navigating options (like comboboxes).

Quiz
In an accessible tab component, what happens when a user presses the Tab key while focused on a tab?

Building an Accessible Accordion

Accordions use a similar pattern to tabs but with vertical orientation and different collapse behavior.

The ARIA Structure

<div class="accordion">
  <h3>
    <button
      aria-expanded="true"
      aria-controls="section-1"
      id="header-1"
    >
      What is your refund policy?
    </button>
  </h3>
  <div
    id="section-1"
    role="region"
    aria-labelledby="header-1"
  >
    <p>Full refund within 30 days, no questions asked.</p>
  </div>

  <h3>
    <button
      aria-expanded="false"
      aria-controls="section-2"
      id="header-2"
    >
      How do I cancel my subscription?
    </button>
  </h3>
  <div
    id="section-2"
    role="region"
    aria-labelledby="header-2"
    hidden
  >
    <p>Go to Settings, then Billing, then Cancel.</p>
  </div>
</div>

Key differences from tabs:

  • Headers are wrapped in heading elements (h3) for document structure
  • Buttons use aria-expanded instead of aria-selected
  • Panels use role="region" with aria-labelledby (not role="tabpanel")
  • Multiple panels can be open simultaneously (unless you specifically want single-expand behavior)

Keyboard Interactions

function handleAccordionKeyDown(event, headers) {
  const currentIndex = headers.indexOf(event.target);
  let newIndex;

  switch (event.key) {
    case 'ArrowDown':
      newIndex = (currentIndex + 1) % headers.length;
      break;
    case 'ArrowUp':
      newIndex = (currentIndex - 1 + headers.length) % headers.length;
      break;
    case 'Home':
      newIndex = 0;
      break;
    case 'End':
      newIndex = headers.length - 1;
      break;
    case 'Enter':
    case ' ':
      toggleSection(event.target);
      event.preventDefault();
      return;
    default:
      return;
  }

  event.preventDefault();
  headers[newIndex].focus();
}

Notice: accordions use Arrow Up/Down (vertical navigation), while tabs use Arrow Left/Right (horizontal navigation). This follows the spatial model — tabs are typically horizontal, accordions are vertical. If you build vertical tabs, use Up/Down arrows instead.

Quiz
An accordion uses ArrowUp and ArrowDown for navigation while tabs use ArrowLeft and ArrowRight. What determines which arrow keys a composite widget should use?

Building an Accessible Combobox (Autocomplete)

The combobox is the most complex ARIA pattern. It combines a text input with a filterable listbox popup. Getting this right is hard — getting it wrong means your search or autocomplete is unusable for keyboard and screen reader users.

The ARIA Structure

<div class="combobox-wrapper">
  <label for="search-input" id="search-label">
    Search countries
  </label>
  <div role="combobox" aria-expanded="true" aria-haspopup="listbox">
    <input
      type="text"
      id="search-input"
      aria-autocomplete="list"
      aria-controls="search-listbox"
      aria-activedescendant="option-2"
      role="combobox"
    />
  </div>
  <ul
    id="search-listbox"
    role="listbox"
    aria-label="Countries"
  >
    <li role="option" id="option-1">Argentina</li>
    <li role="option" id="option-2" aria-selected="true">Australia</li>
    <li role="option" id="option-3">Austria</li>
  </ul>
</div>

The critical attributes:

  • aria-autocomplete="list" tells assistive tech that typing filters a list of suggestions
  • aria-activedescendant points to the currently highlighted option without moving DOM focus from the input. This is the key difference from roving tabindex — focus stays on the input so the user can keep typing
  • aria-expanded communicates whether the listbox popup is visible
  • role="option" and aria-selected on list items communicate selection state

Keyboard Interactions

function handleComboboxKeyDown(event, input, options) {
  const activeIndex = getActiveDescendantIndex(input, options);

  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault();
      if (!isListboxOpen()) {
        openListbox();
        highlightOption(options, 0);
      } else {
        const next = Math.min(activeIndex + 1, options.length - 1);
        highlightOption(options, next);
      }
      break;

    case 'ArrowUp':
      event.preventDefault();
      if (activeIndex > 0) {
        highlightOption(options, activeIndex - 1);
      }
      break;

    case 'Enter':
      if (activeIndex >= 0) {
        event.preventDefault();
        selectOption(options[activeIndex]);
        closeListbox();
      }
      break;

    case 'Escape':
      closeListbox();
      input.value = '';
      break;
  }
}

function highlightOption(options, index) {
  options.forEach(opt => opt.removeAttribute('aria-selected'));
  options[index].setAttribute('aria-selected', 'true');
  input.setAttribute('aria-activedescendant', options[index].id);
  options[index].scrollIntoView({ block: 'nearest' });
}

The tricky part: focus never leaves the input. You use aria-activedescendant to virtually move the "active" indicator while the user keeps typing. This is why comboboxes use aria-activedescendant instead of roving tabindex.

Common Trap

A common mistake is using role="combobox" on the wrapper div and on the input. In ARIA 1.2, the role="combobox" goes directly on the input element. Older patterns from ARIA 1.0/1.1 put it on a wrapping div — this is outdated and causes inconsistent behavior across screen readers. Always follow the ARIA 1.2 pattern: combobox role on the input itself.

Quiz
Why does a combobox use aria-activedescendant instead of roving tabindex for option navigation?

Building an Accessible Tooltip

Tooltips seem simple until you consider all the ways they can fail: keyboard users cannot trigger hover, touch users have no hover at all, and screen readers need to announce the tooltip content.

The ARIA Structure

<button aria-describedby="tooltip-1">
  Save
</button>
<div role="tooltip" id="tooltip-1">
  Save your changes to the current document (Ctrl+S)
</div>

Simple, right? The tricky part is the behavior:

  • role="tooltip" tells assistive tech this is supplementary descriptive text
  • aria-describedby links the trigger to the tooltip — the screen reader announces the tooltip content after the button's name and role
  • The tooltip is additional information, not essential. The trigger must be understandable without it.

Keyboard and Interaction Rules

function setupTooltip(trigger, tooltip) {
  function show() {
    tooltip.removeAttribute('hidden');
  }

  function hide() {
    tooltip.setAttribute('hidden', '');
  }

  trigger.addEventListener('mouseenter', show);
  trigger.addEventListener('mouseleave', hide);
  trigger.addEventListener('focus', show);
  trigger.addEventListener('blur', hide);

  trigger.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
      hide();
    }
  });
}

Key rules for tooltips:

  • Show on hover AND focus — never just hover
  • Hide on Escape — the user must be able to dismiss without moving focus
  • Tooltip content must be hoverable itself (WCAG 1.4.13 Content on Hover or Focus) — the user should be able to move their mouse into the tooltip without it disappearing
  • Never put interactive content (links, buttons) inside a tooltip. If you need that, use a popover or disclosure pattern instead.
  • Add a small delay before showing (150-300ms) to prevent tooltip flicker during casual mouse movement
Warning

Tooltips are for supplementary text only. If the information is essential to complete a task, put it in visible text on the page. A tooltip that contains required instructions is an accessibility failure because screen reader users may never discover it, and touch users cannot trigger hover.

Carousels are controversial — shouldiuseacarousel.com exists for a reason. But if you must build one, here is how to do it accessibly.

The ARIA Structure

<section aria-roledescription="carousel" aria-label="Featured articles">
  <div class="carousel-controls">
    <button aria-label="Previous slide">
      <!-- left arrow icon -->
    </button>
    <button aria-label="Next slide">
      <!-- right arrow icon -->
    </button>
  </div>

  <div aria-live="polite" aria-atomic="false">
    <div
      role="group"
      aria-roledescription="slide"
      aria-label="1 of 4"
    >
      <h3>First Article Title</h3>
      <p>Article description...</p>
    </div>

    <div
      role="group"
      aria-roledescription="slide"
      aria-label="2 of 4"
      hidden
    >
      <h3>Second Article Title</h3>
      <p>Article description...</p>
    </div>
  </div>
</section>

Important details:

  • aria-roledescription="carousel" overrides the generic section role with a more specific name
  • aria-roledescription="slide" on each group tells the screen reader "slide 1 of 4" instead of just "group"
  • aria-live="polite" on the slide container makes the screen reader announce new slides when they appear — but politely, not interrupting current speech
  • aria-label on navigation buttons is essential since they contain only icons
  • Each slide is a role="group" so it can have its own label

Auto-Rotation Rules

If the carousel auto-advances:

  • Provide a pause button — auto-advancing content is a WCAG 2.2.2 violation without one
  • Stop auto-rotation when the user hovers, focuses, or interacts with the carousel
  • Respect prefers-reduced-motion — disable auto-advance entirely for users who prefer reduced motion
const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (!prefersReducedMotion) {
  startAutoRotation();
}

carousel.addEventListener('mouseenter', pauseAutoRotation);
carousel.addEventListener('focusin', pauseAutoRotation);
carousel.addEventListener('mouseleave', resumeAutoRotation);
carousel.addEventListener('focusout', resumeAutoRotation);
Quiz
A carousel auto-advances every 5 seconds. Which of the following makes it WCAG compliant?

Testing with Screen Readers

Building ARIA-compliant markup is step one. Step two is verifying it actually works. Screen readers interpret ARIA differently, and browser/screen-reader combos have their own quirks.

VoiceOver (macOS)

VoiceOver is built into every Mac. Here are the shortcuts you need:

ActionShortcut
Turn on/offCmd + F5
Navigate next elementVO + Right Arrow (VO = Ctrl + Option)
Navigate previous elementVO + Left Arrow
Activate (click)VO + Space
Read current elementVO + F3
Open rotor (navigate by element type)VO + U
Start reading from cursorVO + A

The rotor (VO + U) is especially useful for testing. It lets you browse by headings, links, form controls, and landmarks — exactly how many screen reader users navigate in practice.

NVDA (Windows)

NVDA is free, open-source, and the most popular screen reader for testing. Key shortcuts:

ActionShortcut
Turn on/offCtrl + Alt + N
Navigate next elementDown Arrow (in browse mode)
Navigate previous elementUp Arrow (in browse mode)
Activate (click)Enter
Toggle browse/focus modeNVDA + Space
List headingsNVDA + F7
Next headingH
Next landmarkD

The browse mode vs focus mode distinction is critical. In browse mode, NVDA intercepts all keyboard input for navigation. In focus mode (which activates automatically inside form controls and custom widgets), keystrokes go directly to the page. If your custom widget's keyboard handling does not work in NVDA, check whether the user is stuck in the wrong mode.

What to Test

Run through this checklist for every custom widget:

  1. Tab order — Can you reach every interactive element with Tab? Is the order logical?
  2. Role announcement — Does the screen reader announce the correct role? ("tab, 1 of 3" not just "button")
  3. State changes — When you activate a tab or expand an accordion, does the screen reader announce the state change?
  4. Arrow key navigation — Do arrow keys work as the APG specifies?
  5. Focus management — After an action (opening a modal, selecting a tab), is focus in the right place?
  6. Live region updates — Do dynamic content changes (toast notifications, loading states) get announced?
Screen reader differences you will actually hit

VoiceOver on Safari has the best ARIA support on macOS, but it handles aria-activedescendant differently from NVDA + Firefox. VoiceOver sometimes does not announce aria-activedescendant changes in comboboxes unless you also update aria-selected on the active option. NVDA with Chrome has quirks with role="tabpanel" — it sometimes does not announce the panel label. JAWS (paid, Windows-only) has the largest market share among blind users but the most aggressive interpretation of ARIA — it adds its own virtual cursor behavior. Always test with at least VoiceOver + Safari and NVDA + Chrome/Firefox.

Putting It All Together: The Component Checklist

Before shipping any custom interactive component, verify all of these:

Common Mistakes That Break Accessibility

What developers doWhat they should do
Using only div and span with click handlers for interactive elements
Divs have no implicit role, no keyboard focusability, and no click-on-Enter behavior. You must manually add all of these.
Use native button and a elements where possible, add role and keyboard handling when custom elements are necessary
Adding role='button' but forgetting to handle Enter and Space key presses
ARIA roles communicate semantics to assistive tech but do NOT add behavior. The keyboard handling is your responsibility.
If you use role='button', also add tabindex='0' and keydown handlers for Enter and Space
Using aria-label when visible text already exists on the element
aria-label overrides visible text for screen readers, creating a disconnect between what sighted and non-sighted users experience.
Use the visible text as the accessible name. Only use aria-label when there is no visible text (icon buttons, etc.)
Making every element focusable with tabindex='0' (tab-soup)
Too many tab stops makes keyboard navigation painfully slow. A tablist with 10 tabs should have 1 tab stop, not 10.
Only interactive elements should be focusable. Use roving tabindex or aria-activedescendant for composite widgets
Using aria-live='assertive' for non-critical updates like search suggestions
Assertive announcements interrupt whatever the screen reader is currently saying, which is disorienting and rude for non-critical information.
Use aria-live='polite' for suggestions and non-urgent updates. Reserve 'assertive' for errors and alerts that need immediate attention
Quiz
You add role='button' to a div element. What else must you do to make it fully accessible?
Quiz
You are building a custom select dropdown. A keyboard user presses Tab while the dropdown is open and an option is highlighted. What should happen?

Summary

Building accessible custom components is not about sprinkling aria-label on things until a linter stops complaining. It is about understanding the contract each widget pattern requires — the roles, the keyboard model, the state communication, and the focus management — and implementing every piece of that contract.

The WAI-ARIA Authoring Practices Guide is your blueprint. Screen reader testing is your verification. And native HTML elements are your shortcut whenever they fit.

If you remember one thing from this article: ARIA is a promise to your users. When you add role="tab", you are promising that Arrow keys navigate between tabs, that aria-selected reflects the active tab, and that the screen reader announces all of this clearly. Break any part of that promise and you have made the experience worse than if you had used no ARIA at all.

Key Rules
  1. 1Native HTML first — only reach for ARIA when no native element matches your pattern
  2. 2Roles communicate semantics, not behavior — you must implement keyboard handling yourself
  3. 3Tabs use roving tabindex (Arrow Left/Right), comboboxes use aria-activedescendant (focus stays on input)
  4. 4Every interactive widget needs: correct roles, keyboard navigation, focus management, and state announcements
  5. 5Test with real screen readers — VoiceOver on Safari and NVDA on Chrome at minimum