Custom Widgets Accessibility — Complete Guide
In this tutorial, you will learn about Custom Widgets Accessibility. We cover key concepts, practical examples, and best practices to help you master this topic.
Custom widgets like tabs, accordions, carousels, tree views, and sliders need ARIA roles, keyboard navigation patterns, and state management to be accessible to assistive technology users.
What You'll Learn
- The WAI-ARIA Authoring Practices patterns for common widgets
- Tab, accordion, carousel, slider, and tree view accessibility
- Keyboard interaction patterns for each widget type
- ARIA roles and states for each widget
- Testing custom widgets with screen readers
Why It Matters
- Custom widgets are where accessibility most commonly fails
- Native HTML cannot Express complex widget semantics
- WAI-ARIA provides tested patterns for common widgets
- Many popular UI component libraries have accessibility gaps
Real-World Use
- A product page uses accessible tabs for description, reviews, shipping
- A FAQ section uses an accessible accordion pattern
- A dashboard uses an accessible slider for filtering price ranges
- A file manager uses an accessible tree view for folder navigation
flowchart LR
A[Custom Widget] --> B{Widget Type}
B --> C[Tabs]
B --> D[Accordion]
B --> E[Carousel]
B --> F[Slider]
B --> G[Tree View]
C --> H[tablist, tab, tabpanel]
D --> I[button, aria-expanded]
E --> J[region, aria-live]
F --> K[slider, valuenow]
G --> L[tree, treeitem]
Widget Patterns
Every custom widget must follow a recognized accessibility pattern. The WAI-ARIA Authoring Practices guide provides tested patterns for 40+ widgets.
Tabs
Tabs display one panel at a time from a set of related panels. The tablist, tab, and tabpanel roles create the semantic structure.
Code Example: Accessible Tabs
<div class="tabs">
<div role="tablist" aria-label="Product information">
<button role="tab"
aria-selected="true"
aria-controls="panel-desc"
id="tab-desc"
tabindex="0"
onclick="switchTab('panel-desc')"
onkeydown="handleTabKey(event, 'tab-desc')">
Description
</button>
<button role="tab"
aria-selected="false"
aria-controls="panel-reviews"
id="tab-reviews"
tabindex="-1"
onclick="switchTab('panel-reviews')"
onkeydown="handleTabKey(event, 'tab-reviews')">
Reviews
</button>
<button role="tab"
aria-selected="false"
aria-controls="panel-shipping"
id="tab-shipping"
tabindex="-1"
onclick="switchTab('panel-shipping')"
onkeydown="handleTabKey(event, 'tab-shipping')">
Shipping
</button>
</div>
<div role="tabpanel" id="panel-desc" aria-labelledby="tab-desc">
<p>This handcrafted ceramic vase measures 12 inches tall...</p>
</div>
<div role="tabpanel" id="panel-reviews" aria-labelledby="tab-reviews" hidden>
<p>4.8 out of 5 stars based on 120 reviews...</p>
</div>
<div role="tabpanel" id="panel-shipping" aria-labelledby="tab-shipping" hidden>
<p>Ships within 2-3 business days via ground shipping...</p>
</div>
</div>
<script>
function switchTab(panelId) {
document.querySelectorAll('[role=tabpanel]').forEach(p => p.hidden = true);
document.querySelectorAll('[role=tab]').forEach(t => {
t.setAttribute('aria-selected', 'false');
t.tabIndex = -1;
});
document.getElementById(panelId).hidden = false;
const tab = document.querySelector(`[aria-controls="${panelId}"]`);
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
tab.focus();
}
function handleTabKey(event, tabId) {
const tabs = Array.from(document.querySelectorAll('[role=tab]'));
const idx = tabs.indexOf(document.getElementById(tabId));
let newIdx;
switch(event.key) {
case 'ArrowRight': newIdx = (idx + 1) % tabs.length; break;
case 'ArrowLeft': newIdx = (idx - 1 + tabs.length) % tabs.length; break;
case 'Home': newIdx = 0; break;
case 'End': newIdx = tabs.length - 1; break;
default: return;
}
event.preventDefault();
switchTab(tabs[newIdx].getAttribute('aria-controls'));
}
</script>
Expected output: Screen readers announce "Description tab, selected" and "Reviews tab." Arrow keys move between tabs. Tab moves into the active panel. The tabpanel announces "Description tab panel."
Code Example: Accessible Accordion
<div id="faq-accordion">
<h3>
<button aria-expanded="false"
aria-controls="faq1-panel"
id="faq1-trigger"
onclick="toggleAccordion('faq1')">
What is your return policy?
</button>
</h3>
<div id="faq1-panel"
role="region"
aria-labelledby="faq1-trigger"
hidden>
<p>Items can be returned within 30 days of purchase. Contact support for a return label.</p>
</div>
<h3>
<button aria-expanded="false"
aria-controls="faq2-panel"
id="faq2-trigger"
onclick="toggleAccordion('faq2')">
Do you ship internationally?
</button>
</h3>
<div id="faq2-panel"
role="region"
aria-labelledby="faq2-trigger"
hidden>
<p>Yes, we ship to over 50 countries. International delivery takes 5-10 business days.</p>
</div>
<h3>
<button aria-expanded="false"
aria-controls="faq3-panel"
id="faq3-trigger"
onclick="toggleAccordion('faq3')">
How do I track my order?
</button>
</h3>
<div id="faq3-panel"
role="region"
aria-labelledby="faq3-trigger"
hidden>
<p>You will receive a tracking link via email once your order ships.</p>
</div>
</div>
<script>
function toggleAccordion(id) {
const panel = document.getElementById(`${id}-panel`);
const trigger = document.getElementById(`${id}-trigger`);
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
trigger.setAttribute('aria-expanded', !isExpanded);
panel.hidden = isExpanded;
}
</script>
Expected output: Screen readers announce each accordion button with its state. "What is your return policy, collapsed" becomes "expanded" when clicked. The region role connects each button to its panel.
Code Example: Accessible Slider
<div style="width:400px; padding:1rem;">
<label for="price-slider" id="price-label">Maximum Price: <span id="price-value">$500</span></label>
<div role="slider"
id="price-slider"
aria-labelledby="price-label"
aria-valuemin="0"
aria-valuemax="1000"
aria-valuenow="500"
aria-valuetext="$500"
tabindex="0"
onkeydown="handleSliderKey(event)"
style="width:100%; height:20px; background:#ddd; border-radius:10px; position:relative; cursor:pointer;">
<div id="slider-thumb"
style="position:absolute; left:50%; top:-5px; width:30px; height:30px; border-radius:50%; background:#0056B3; border:2px solid white; box-shadow:0 2px 4px rgba(0,0,0,0.3); transform:translateX(-50%);">
</div>
</div>
</div>
<script>
function handleSliderKey(event) {
const slider = event.currentTarget;
let value = parseInt(slider.getAttribute('aria-valuenow'));
const min = parseInt(slider.getAttribute('aria-valuemin'));
const max = parseInt(slider.getAttribute('aria-valuemax'));
switch(event.key) {
case 'ArrowRight':
case 'ArrowUp':
value = Math.min(max, value + 10);
break;
case 'ArrowLeft':
case 'ArrowDown':
value = Math.max(min, value - 10);
break;
case 'Home':
value = min;
break;
case 'End':
value = max;
break;
default:
return;
}
event.preventDefault();
updateSlider(slider, value);
}
function updateSlider(slider, value) {
const percent = (value / parseInt(slider.getAttribute('aria-valuemax'))) * 100;
slider.setAttribute('aria-valuenow', value);
slider.setAttribute('aria-valuetext', `$${value}`);
document.getElementById('price-value').textContent = `$${value}`;
document.getElementById('slider-thumb').style.left = `${percent}%`;
}
</script>
Expected output: Screen readers announce "Maximum Price: slider, $500." Arrow keys increment and decrement the value. Home goes to minimum ($0), End goes to maximum ($1000). The value is announced with each change.
Common Mistakes
- Missing keyboard navigation for custom widgets — Tabs need Arrow keys, accordions need Enter/Space, sliders need Arrow/Home/End. Custom widgets without keyboard support are inaccessible.
- Wrong ARIA role selection — Using
role="button"on a tab component instead ofrole="tab"breaks the expected interaction pattern. - Not managing tabindex — Only one tab in a tablist should have tabindex="0". The rest should be tabindex="-1" so Tab moves through the tablist, not between individual tabs.
- Missing aria-controls relationship — Without aria-controls linking the trigger to the controlled content, screen readers do not announce the relationship.
- State not updated — aria-expanded, aria-selected, and aria-valuenow must be updated via JavaScript when the widget state changes.
- Carousel auto-rotates without pause — Carousels that auto-rotate must have a pause button and respect prefers-reduced-motion.
- Not following WAI-ARAP Authoring Practices — Guessing the pattern leads to inconsistent or incorrect implementations. Always reference the official patterns.
Practice Questions
- What keyboard keys should navigate between tabs in a tablist? Arrow keys (Left/Right) and Home/End for first/last tab.
- What ARIA attribute links an accordion button to its content panel? aria-controls on the button, referencing the id of the content panel.
- What ARIA attributes are required for a slider widget? role="slider", aria-valuemin, aria-valuemax, aria-valuenow, and aria-labelledby or aria-label.
- Why should only one tab in a tablist have tabindex="0"? So pressing Tab once enters the tablist, and Arrow keys navigate between tabs. This prevents excessive Tab presses.
- Challenge: Build an accessible tree view widget (for a file explorer) that supports: expand/collapse with Arrow Right/Left, navigation with Arrow Up/Down, selection with Enter, and Home/End for first/last items. Use role="tree", role="treeitem", aria-expanded, aria-selected, and aria-setsize/aria-posinset. Include at least 3 levels of nesting.
FAQ
{{< faq "Can I use a div as a tab?" "Yes, with role=\"tab\", tabindex=\"0\" (for the selected tab), aria-selected, and aria-controls. But a button element is simpler and already keyboard accessible." >}}Mini Project
Build a fully accessible product configuration widget (like a car configurator). Include: a tab panel for different categories (Engine, Color, Wheels, Interior), a radio group for options within each tab (with role="radiogroup"), a live region that updates the total price, a slider for a numeric option (like financing months), and a summary accordion that shows the selected configuration. Implement full keyboard navigation, proper ARIA states, and focus management. Test with keyboard only and a screen reader. Document the interaction patterns.
What's Next
Continue with Lesson 20: CSS Accessibility to learn how CSS can improve or harm accessibility.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro