Building Accessible Custom Components
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.
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:
- Find the pattern — Look up the widget you are building (tabs, combobox, accordion, etc.)
- Read the keyboard interaction — This defines exactly which keys do what
- Apply roles, states, properties — These tell assistive technology what the widget is and what state it is in
- Test with a screen reader — The spec is necessary but not sufficient. Real testing catches what specs miss.
- 1Always start from WAI-ARIA Authoring Practices patterns before inventing your own interaction model
- 2Every custom interactive widget needs explicit roles, keyboard handling, and ARIA states
- 3ARIA is a last resort — if a native HTML element does the job, use it instead
- 4role, tabindex, and aria-* attributes do not add behavior — you must implement keyboard handling yourself
- 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 activearia-controlscreates a programmatic link between the tab and its panelrole="tabpanel"identifies the content areaaria-labelledbygives 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).
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-expandedinstead ofaria-selected - Panels use
role="region"witharia-labelledby(notrole="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.
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 suggestionsaria-activedescendantpoints 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 typingaria-expandedcommunicates whether the listbox popup is visiblerole="option"andaria-selectedon 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.
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.
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 textaria-describedbylinks 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
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.
Building an Accessible Carousel
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 genericsectionrole with a more specific namearia-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 speecharia-labelon 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);
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:
| Action | Shortcut |
|---|---|
| Turn on/off | Cmd + F5 |
| Navigate next element | VO + Right Arrow (VO = Ctrl + Option) |
| Navigate previous element | VO + Left Arrow |
| Activate (click) | VO + Space |
| Read current element | VO + F3 |
| Open rotor (navigate by element type) | VO + U |
| Start reading from cursor | VO + 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:
| Action | Shortcut |
|---|---|
| Turn on/off | Ctrl + Alt + N |
| Navigate next element | Down Arrow (in browse mode) |
| Navigate previous element | Up Arrow (in browse mode) |
| Activate (click) | Enter |
| Toggle browse/focus mode | NVDA + Space |
| List headings | NVDA + F7 |
| Next heading | H |
| Next landmark | D |
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:
- Tab order — Can you reach every interactive element with Tab? Is the order logical?
- Role announcement — Does the screen reader announce the correct role? ("tab, 1 of 3" not just "button")
- State changes — When you activate a tab or expand an accordion, does the screen reader announce the state change?
- Arrow key navigation — Do arrow keys work as the APG specifies?
- Focus management — After an action (opening a modal, selecting a tab), is focus in the right place?
- 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 do | What 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 |
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.
- 1Native HTML first — only reach for ARIA when no native element matches your pattern
- 2Roles communicate semantics, not behavior — you must implement keyboard handling yourself
- 3Tabs use roving tabindex (Arrow Left/Right), comboboxes use aria-activedescendant (focus stays on input)
- 4Every interactive widget needs: correct roles, keyboard navigation, focus management, and state announcements
- 5Test with real screen readers — VoiceOver on Safari and NVDA on Chrome at minimum