Interactive Components (Modals, Dropdowns, Tabs)
1. Button States (aria-pressed, disabled) and Properties
| Button Type | Element | Required Attributes | Use Case |
|---|---|---|---|
| Submit Button | <button type="submit"> |
None (native behavior) | Form submission |
| Reset Button | <button type="reset"> |
None | Clear form fields |
| Regular Button | <button type="button"> |
None | JavaScript actions |
| Link Button | <a href="..." role="button"> |
role="button" (if styled as button) |
Navigation that looks like button |
| Custom Button | <div role="button"> |
role="button", tabindex="0" |
Only when native button impossible |
| Toggle Button | <button aria-pressed> |
aria-pressed="true/false" |
On/off state (bold, italic) |
| Menu Button | <button aria-haspopup> |
aria-haspopup="menu", aria-expanded |
Opens dropdown menu |
Example: Button patterns and states
<!-- Basic buttons -->
<button type="button" onclick="save()">Save</button>
<button type="submit">Submit Form</button>
<button type="reset">Clear</button>
<!-- Disabled button -->
<button disabled>Unavailable Action</button>
<button aria-disabled="true" onclick="showWarning()">
Disabled but still focusable
</button>
<!-- Toggle button (pressed state) -->
<button
type="button"
aria-pressed="false"
onclick="this.setAttribute('aria-pressed',
this.getAttribute('aria-pressed') === 'false' ? 'true' : 'false')">
<span aria-hidden="true">★</span> Favorite
</button>
<!-- Menu button -->
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="menu-dropdown"
id="menu-button">
Options ▼
</button>
<ul id="menu-dropdown" role="menu" hidden>
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>
<!-- Button with loading state -->
<button type="submit" id="submit-btn">
<span class="btn-content">Save Changes</span>
<span class="btn-loading" hidden>
<span class="spinner" aria-hidden="true"></span>
Saving...
</span>
</button>
<!-- Icon-only button (requires label) -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true">...close icon...</svg>
</button>
<!-- Button with description -->
<button
type="button"
aria-describedby="delete-help">
Delete Account
</button>
<span id="delete-help" class="help-text">
This action cannot be undone
</span>
| ARIA Attribute | Values | Purpose | Example Use |
|---|---|---|---|
| aria-label | String | Accessible name for icon-only buttons | Close, Menu, Search buttons |
| aria-labelledby | ID reference | Label button with another element's text | Button labeled by heading |
| aria-describedby | ID reference(s) | Additional context or help text | Destructive actions with warnings |
| aria-pressed | true/false/mixed | Toggle button state | Bold, Italic, Favorite toggles |
| aria-expanded | true/false | Expandable content state | Dropdown, accordion buttons |
| aria-haspopup | menu/dialog/grid/listbox/tree | Indicates popup type | Menu buttons, comboboxes |
| aria-controls | ID reference | Element controlled by button | Tab panels, dropdowns |
| aria-disabled | true/false | Disabled but focusable (vs disabled attr) | Disabled with tooltip explanation |
| aria-busy | true/false | Loading/processing state | Submit buttons during API call |
Warning: Don't use
<div> or <span> for buttons unless
absolutely necessary. Native <button> elements work better with assistive tech and handle
keyboard interaction automatically.
| disabled vs aria-disabled | Behavior | When to Use |
|---|---|---|
| disabled attribute | Not focusable, not in tab order, grayed out | Standard disabled state - user can't interact |
| aria-disabled="true" | Still focusable, in tab order, can show tooltip | Need to explain why disabled (tooltip on focus) |
2. Modal and Dialog Implementation (role="dialog", aria-modal)
| Dialog Type | Role | Behavior | Use Case |
|---|---|---|---|
| Modal Dialog | role="dialog" + aria-modal="true" |
Blocks interaction with background, focus trap | Confirmations, forms requiring attention |
| Alert Dialog | role="alertdialog" |
Interrupts workflow, requires response | Critical warnings, errors |
| Non-Modal Dialog | role="dialog" without aria-modal |
Allows background interaction | Inspectors, tool palettes (rare) |
Example: Complete modal dialog implementation
<!-- Trigger button -->
<button type="button" onclick="openDialog()">Open Dialog</button>
<!-- Modal dialog -->
<div
id="my-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
class="modal"
hidden>
<!-- Backdrop -->
<div class="modal-backdrop" onclick="closeDialog()"></div>
<!-- Dialog content -->
<div class="modal-content">
<div class="modal-header">
<h2 id="dialog-title">Confirm Action</h2>
<button
type="button"
aria-label="Close dialog"
onclick="closeDialog()"
class="close-btn">
×
</button>
</div>
<div class="modal-body">
<p id="dialog-desc">
Are you sure you want to delete this item? This action cannot be undone.
</p>
</div>
<div class="modal-footer">
<button type="button" onclick="closeDialog()">Cancel</button>
<button type="button" onclick="confirmAction()" class="btn-danger">
Delete
</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal[hidden] {
display: none;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: white;
max-width: 500px;
width: 90%;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 1;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<script>
let previousFocus = null;
const dialog = document.getElementById('my-dialog');
function openDialog() {
// Store previous focus
previousFocus = document.activeElement;
// Make background inert
document.body.style.overflow = 'hidden';
// Show dialog
dialog.hidden = false;
// Focus first focusable element (close button or first action)
const firstFocusable = dialog.querySelector('button');
firstFocusable.focus();
// Setup focus trap
setupFocusTrap();
// Listen for Escape key
document.addEventListener('keydown', handleEscape);
}
function closeDialog() {
// Hide dialog
dialog.hidden = true;
// Restore body scroll
document.body.style.overflow = '';
// Remove escape listener
document.removeEventListener('keydown', handleEscape);
// Restore focus
if (previousFocus) {
previousFocus.focus();
}
}
function handleEscape(e) {
if (e.key === 'Escape') {
closeDialog();
}
}
function setupFocusTrap() {
const focusableElements = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
dialog.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
});
}
function confirmAction() {
console.log('Action confirmed');
closeDialog();
}
</script>
| Modal Requirement | Implementation | WCAG Criteria |
|---|---|---|
| Focus Management | Focus first element on open, restore on close | 2.4.3 Focus Order |
| Focus Trap | Tab cycles only within dialog | 2.1.2 No Keyboard Trap |
| Escape to Close | Escape key dismisses dialog | 2.1.1 Keyboard |
| Accessible Name | aria-labelledby references title |
4.1.2 Name, Role, Value |
| Description | aria-describedby for main content |
4.1.2 Name, Role, Value |
| Background Interaction | aria-modal="true" or inert on background |
2.4.3 Focus Order |
| Initial Focus | Focus close button, first action, or first field | Best practice |
Note: Use
<dialog> HTML element when browser support allows. It handles
focus trap and backdrop automatically. Call dialog.showModal() to open.
3. Dropdown and Combobox Patterns (role="combobox", aria-controls)
| Pattern | Role | Keyboard | Use Case |
|---|---|---|---|
| Simple Dropdown | role="menu" + role="menuitem" |
↓↑ navigate, Enter select, Esc close | Action menus (Edit, Delete, Share) |
| Select-Only Combobox | role="combobox" + role="listbox" |
↓↑ navigate, Enter select, type-ahead | Country selector, category picker |
| Editable Combobox | role="combobox" + input + role="listbox" |
Type to filter, ↓↑ navigate, Enter select | Autocomplete search, tag input |
| Native Select | <select> element |
Native browser behavior | Preferred when possible - best support |
Example: Accessible dropdown menu
<!-- Dropdown menu button -->
<div class="dropdown">
<button
type="button"
id="menu-button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="menu-list"
onclick="toggleMenu()">
Actions ▼
</button>
<ul
id="menu-list"
role="menu"
aria-labelledby="menu-button"
hidden>
<li role="none">
<button role="menuitem" onclick="edit()">Edit</button>
</li>
<li role="none">
<button role="menuitem" onclick="duplicate()">Duplicate</button>
</li>
<li role="separator"></li>
<li role="none">
<button role="menuitem" onclick="deleteItem()">Delete</button>
</li>
</ul>
</div>
<script>
const menuButton = document.getElementById('menu-button');
const menuList = document.getElementById('menu-list');
const menuItems = menuList.querySelectorAll('[role="menuitem"]');
let currentIndex = -1;
function toggleMenu() {
const isOpen = menuButton.getAttribute('aria-expanded') === 'true';
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}
function openMenu() {
menuButton.setAttribute('aria-expanded', 'true');
menuList.hidden = false;
currentIndex = 0;
menuItems[0].focus();
// Add event listeners
document.addEventListener('click', handleOutsideClick);
menuList.addEventListener('keydown', handleMenuKeydown);
}
function closeMenu() {
menuButton.setAttribute('aria-expanded', 'false');
menuList.hidden = true;
menuButton.focus();
currentIndex = -1;
// Remove event listeners
document.removeEventListener('click', handleOutsideClick);
menuList.removeEventListener('keydown', handleMenuKeydown);
}
function handleMenuKeydown(e) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
currentIndex = (currentIndex + 1) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
currentIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
menuItems[currentIndex].focus();
break;
case 'Home':
e.preventDefault();
currentIndex = 0;
menuItems[0].focus();
break;
case 'End':
e.preventDefault();
currentIndex = menuItems.length - 1;
menuItems[currentIndex].focus();
break;
case 'Escape':
e.preventDefault();
closeMenu();
break;
case 'Tab':
e.preventDefault();
closeMenu();
break;
}
}
function handleOutsideClick(e) {
if (!menuList.contains(e.target) && e.target !== menuButton) {
closeMenu();
}
}
</script>
Example: Autocomplete combobox with filtering
<!-- Autocomplete combobox -->
<div class="combobox-wrapper">
<label for="country-input">Country</label>
<input
type="text"
id="country-input"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="country-listbox"
aria-activedescendant=""
autocomplete="off"
placeholder="Type to search...">
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
hidden>
</ul>
</div>
<script>
const countries = [
'United States', 'United Kingdom', 'Canada', 'Australia',
'Germany', 'France', 'Spain', 'Italy', 'Japan', 'China'
];
const input = document.getElementById('country-input');
const listbox = document.getElementById('country-listbox');
let currentOption = -1;
input.addEventListener('input', (e) => {
const value = e.target.value.toLowerCase();
if (!value) {
closeListbox();
return;
}
// Filter countries
const filtered = countries.filter(country =>
country.toLowerCase().includes(value)
);
// Populate listbox
listbox.innerHTML = filtered.map((country, index) => `
<li
role="option"
id="option-${index}"
onclick="selectOption('${country}')">
${country}
</li>
`).join('');
if (filtered.length > 0) {
openListbox();
} else {
closeListbox();
}
});
input.addEventListener('keydown', (e) => {
const options = listbox.querySelectorAll('[role="option"]');
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (input.getAttribute('aria-expanded') === 'false') {
openListbox();
}
currentOption = Math.min(currentOption + 1, options.length - 1);
updateActiveDescendant(options);
break;
case 'ArrowUp':
e.preventDefault();
currentOption = Math.max(currentOption - 1, 0);
updateActiveDescendant(options);
break;
case 'Enter':
e.preventDefault();
if (currentOption >= 0 && options[currentOption]) {
selectOption(options[currentOption].textContent.trim());
}
break;
case 'Escape':
closeListbox();
break;
}
});
function updateActiveDescendant(options) {
options.forEach((opt, idx) => {
opt.classList.toggle('selected', idx === currentOption);
});
if (options[currentOption]) {
input.setAttribute('aria-activedescendant', options[currentOption].id);
}
}
function openListbox() {
input.setAttribute('aria-expanded', 'true');
listbox.hidden = false;
currentOption = -1;
}
function closeListbox() {
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-activedescendant', '');
listbox.hidden = true;
currentOption = -1;
}
function selectOption(value) {
input.value = value;
closeListbox();
input.focus();
}
// Close on outside click
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !listbox.contains(e.target)) {
closeListbox();
}
});
</script>
| Combobox Attribute | Value | Purpose |
|---|---|---|
| role="combobox" | - | Identifies the input as a combobox |
| aria-autocomplete | list/inline/both/none | Indicates autocomplete behavior |
| aria-expanded | true/false | Whether listbox is visible |
| aria-controls | ID of listbox | Links input to dropdown list |
| aria-activedescendant | ID of active option | Current highlighted option (virtual focus) |
| aria-owns | ID(s) | Alternative to aria-controls (older pattern) |
Warning: Combobox is one of the most complex ARIA patterns. Use native
<select> or <datalist> when possible. Only implement custom combobox when
absolutely necessary.
4. Tab Panel Components (role="tablist", "tab", "tabpanel")
| Element | Role | Required Attributes | Purpose |
|---|---|---|---|
| Tab Container | role="tablist" |
aria-label or aria-labelledby |
Container for tabs |
| Individual Tab | role="tab" |
aria-selected, aria-controls, tabindex |
Tab trigger button |
| Tab Panel | role="tabpanel" |
aria-labelledby, tabindex="0" |
Content area for active tab |
Example: Complete tab panel implementation
<div class="tabs-container">
<!-- Tab list -->
<div role="tablist" aria-label="Content sections">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
Overview
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Features
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
tabindex="-1">
Pricing
</button>
</div>
<!-- Tab panels -->
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
tabindex="0">
<h3>Overview Content</h3>
<p>This is the overview section...</p>
</div>
<div
role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
tabindex="0"
hidden>
<h3>Features Content</h3>
<p>This is the features section...</p>
</div>
<div
role="tabpanel"
id="panel-3"
aria-labelledby="tab-3"
tabindex="0"
hidden>
<h3>Pricing Content</h3>
<p>This is the pricing section...</p>
</div>
</div>
<style>
.tabs-container {
border: 1px solid #ddd;
border-radius: 4px;
}
[role="tablist"] {
display: flex;
border-bottom: 2px solid #ddd;
background: #f5f5f5;
}
[role="tab"] {
padding: 12px 24px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
[role="tab"]:hover {
background: #e0e0e0;
}
[role="tab"]:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
z-index: 1;
}
[role="tab"][aria-selected="true"] {
background: white;
border-bottom: 3px solid #0066cc;
font-weight: 600;
color: #0066cc;
}
[role="tabpanel"] {
padding: 20px;
}
[role="tabpanel"]:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
}
</style>
<script>
class TabWidget {
constructor(tablist) {
this.tablist = tablist;
this.tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
this.panels = this.tabs.map(tab =>
document.getElementById(tab.getAttribute('aria-controls'))
);
this.currentIndex = this.tabs.findIndex(
tab => tab.getAttribute('aria-selected') === 'true'
);
this.setupEventListeners();
}
setupEventListeners() {
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', () => this.selectTab(index));
tab.addEventListener('keydown', (e) => this.handleKeydown(e, index));
});
}
selectTab(index) {
// Deselect all tabs
this.tabs.forEach((tab, i) => {
const isSelected = i === index;
tab.setAttribute('aria-selected', isSelected);
tab.setAttribute('tabindex', isSelected ? '0' : '-1');
// Show/hide panels
if (this.panels[i]) {
this.panels[i].hidden = !isSelected;
}
});
// Focus selected tab
this.tabs[index].focus();
this.currentIndex = index;
}
handleKeydown(e, currentIndex) {
let newIndex = currentIndex;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
this.selectTab(newIndex);
break;
case 'ArrowRight':
e.preventDefault();
newIndex = (currentIndex + 1) % this.tabs.length;
this.selectTab(newIndex);
break;
case 'Home':
e.preventDefault();
this.selectTab(0);
break;
case 'End':
e.preventDefault();
this.selectTab(this.tabs.length - 1);
break;
}
}
}
// Initialize all tab widgets
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
new TabWidget(tablist);
});
</script>
| Tab Pattern Best Practice | Implementation | Reason |
|---|---|---|
| Roving tabindex | Selected tab: tabindex="0", others: tabindex="-1" |
Only one tab in tab order at a time |
| Arrow Key Navigation | Left/Right arrows move between tabs | Standard tab pattern expectation |
| Automatic Activation | Arrow keys both focus AND activate tab | Recommended (vs manual activation) |
| Home/End Keys | Jump to first/last tab | Convenience for many tabs |
| Panel Focusable | Panels have tabindex="0" |
Allows keyboard users to scroll panel |
| aria-selected | Only selected tab has aria-selected="true" |
Communicates state to screen readers |
Note: There are two tab activation patterns: Automatic (arrow
keys activate immediately - recommended) and Manual (arrow keys focus,
Enter/Space activates). Automatic is more common and preferred.
5. Accordion and Disclosure Widgets (aria-expanded)
| Pattern | HTML Element | ARIA Pattern | Use Case |
|---|---|---|---|
| Native Disclosure | <details> + <summary> |
None (built-in) | Simple show/hide content (preferred) |
| ARIA Disclosure | <button aria-expanded> |
aria-expanded, aria-controls |
Custom styled disclosure |
| Accordion | Multiple disclosure widgets | Same as disclosure, grouped | FAQ sections, settings panels |
Example: Native details/summary (preferred)
<!-- Native disclosure (best accessibility) -->
<details>
<summary>What is accessibility?</summary>
<p>
Accessibility ensures that websites and applications can be used by
everyone, including people with disabilities who may use assistive
technologies like screen readers.
</p>
</details>
<details open>
<summary>Why is it important?</summary>
<p>
It's not just a legal requirement - it makes your content available
to a wider audience and improves usability for everyone.
</p>
</details>
<style>
details {
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
margin-bottom: 8px;
}
summary {
cursor: pointer;
font-weight: 600;
padding: 8px 0;
list-style: none; /* Remove default marker */
display: flex;
align-items: center;
}
/* Custom marker */
summary::before {
content: "▶";
margin-right: 8px;
transition: transform 0.2s;
display: inline-block;
}
details[open] summary::before {
transform: rotate(90deg);
}
/* Remove default marker in WebKit */
summary::-webkit-details-marker {
display: none;
}
summary:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
border-radius: 2px;
}
details p {
margin-top: 12px;
padding-left: 24px;
}
</style>
Example: Custom accordion with ARIA
<!-- Custom accordion -->
<div class="accordion">
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="section1"
id="accordion1"
class="accordion-trigger">
<span class="accordion-title">Personal Information</span>
<span class="accordion-icon" aria-hidden="true">+</span>
</button>
</h3>
<div
id="section1"
role="region"
aria-labelledby="accordion1"
class="accordion-panel"
hidden>
<p>Content for personal information section...</p>
</div>
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="section2"
id="accordion2"
class="accordion-trigger">
<span class="accordion-title">Account Settings</span>
<span class="accordion-icon" aria-hidden="true">+</span>
</button>
</h3>
<div
id="section2"
role="region"
aria-labelledby="accordion2"
class="accordion-panel"
hidden>
<p>Content for account settings section...</p>
</div>
<h3>
<button
type="button"
aria-expanded="false"
aria-controls="section3"
id="accordion3"
class="accordion-trigger">
<span class="accordion-title">Privacy Options</span>
<span class="accordion-icon" aria-hidden="true">+</span>
</button>
</h3>
<div
id="section3"
role="region"
aria-labelledby="accordion3"
class="accordion-panel"
hidden>
<p>Content for privacy options section...</p>
</div>
</div>
<style>
.accordion {
border: 1px solid #ddd;
border-radius: 4px;
}
.accordion h3 {
margin: 0;
border-bottom: 1px solid #ddd;
}
.accordion h3:last-of-type {
border-bottom: none;
}
.accordion-trigger {
width: 100%;
padding: 16px;
border: none;
background: white;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
transition: background 0.2s;
}
.accordion-trigger:hover {
background: #f5f5f5;
}
.accordion-trigger:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
z-index: 1;
}
.accordion-trigger[aria-expanded="true"] {
background: #f0f8ff;
}
.accordion-icon {
font-size: 24px;
font-weight: bold;
transition: transform 0.2s;
}
.accordion-trigger[aria-expanded="true"] .accordion-icon {
transform: rotate(45deg);
}
.accordion-panel {
padding: 16px;
border-top: 1px solid #ddd;
}
.accordion-panel[hidden] {
display: none;
}
</style>
<script>
class Accordion {
constructor(element) {
this.accordion = element;
this.triggers = Array.from(element.querySelectorAll('.accordion-trigger'));
this.allowMultiple = element.hasAttribute('data-allow-multiple');
this.triggers.forEach((trigger, index) => {
trigger.addEventListener('click', () => this.toggle(index));
});
}
toggle(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
// Close other panels if not allowing multiple
if (!this.allowMultiple && !isExpanded) {
this.triggers.forEach((t, i) => {
if (i !== index) {
this.collapse(i);
}
});
}
// Toggle current panel
if (isExpanded) {
this.collapse(index);
} else {
this.expand(index);
}
}
expand(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
trigger.setAttribute('aria-expanded', 'true');
panel.hidden = false;
}
collapse(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
trigger.setAttribute('aria-expanded', 'false');
panel.hidden = true;
}
}
// Initialize all accordions
document.querySelectorAll('.accordion').forEach(accordion => {
new Accordion(accordion);
});
</script>
| Accordion Variant | Behavior | Implementation |
|---|---|---|
| Single Expansion | Opening one section closes others | Default accordion behavior |
| Multiple Expansion | Multiple sections can be open | Add data-allow-multiple attribute |
| All Collapsed | All sections can be closed | Default - no section required to be open |
| Always One Open | At least one section must be open | Prevent closing last open section |
Note: Use native
<details>/<summary> when possible - it requires no
JavaScript, works in all modern browsers, and has excellent accessibility support.
6. Custom Widget Development
| Development Step | Consideration | Resources |
|---|---|---|
| 1. Check Native Options | Can native HTML element meet needs? | HTML5 elements, form controls |
| 2. Review ARIA Patterns | Is there an established ARIA pattern? | WAI-ARIA Authoring Practices Guide (APG) |
| 3. Define Keyboard Interaction | What keys should do what? | Follow APG keyboard patterns |
| 4. Implement ARIA | Roles, states, properties | ARIA specification |
| 5. Add Focus Management | Where should focus go when? | Focus trap, roving tabindex patterns |
| 6. Test with AT | Screen readers, keyboard only | NVDA, JAWS, VoiceOver, TalkBack |
Example: Custom widget checklist and template
// Custom Widget Development Template
class CustomWidget {
constructor(element) {
this.element = element;
this.isInitialized = false;
// Validate element exists
if (!this.element) {
console.error('Widget element not found');
return;
}
this.init();
}
init() {
// 1. Set ARIA roles and initial state
this.setupARIA();
// 2. Setup keyboard interaction
this.setupKeyboard();
// 3. Setup mouse/touch interaction
this.setupPointer();
// 4. Setup focus management
this.setupFocus();
this.isInitialized = true;
}
setupARIA() {
// Set role if not already present
if (!this.element.hasAttribute('role')) {
this.element.setAttribute('role', 'widget-role');
}
// Set initial ARIA states
this.element.setAttribute('aria-label', 'Widget name');
this.element.setAttribute('tabindex', '0');
// Set dynamic ARIA properties
this.updateARIAState();
}
updateARIAState() {
// Update ARIA states based on widget state
// Example: aria-expanded, aria-selected, aria-checked, etc.
}
setupKeyboard() {
this.element.addEventListener('keydown', (e) => {
switch(e.key) {
case 'Enter':
case ' ':
e.preventDefault();
this.activate();
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
e.preventDefault();
this.navigate(e.key);
break;
case 'Home':
e.preventDefault();
this.navigateToFirst();
break;
case 'End':
e.preventDefault();
this.navigateToLast();
break;
case 'Escape':
e.preventDefault();
this.close();
break;
case 'Tab':
// Allow default tab behavior unless in focus trap
if (this.isFocusTrapped) {
e.preventDefault();
this.handleTabInTrap(e.shiftKey);
}
break;
}
});
}
setupPointer() {
// Click events
this.element.addEventListener('click', (e) => {
this.handleClick(e);
});
// Touch events for mobile
this.element.addEventListener('touchstart', (e) => {
this.handleTouch(e);
}, { passive: true });
}
setupFocus() {
// Focus events
this.element.addEventListener('focus', () => {
this.onFocus();
});
this.element.addEventListener('blur', () => {
this.onBlur();
});
}
// Widget-specific methods
activate() {
console.log('Widget activated');
this.updateARIAState();
}
navigate(direction) {
console.log('Navigate:', direction);
}
navigateToFirst() {
console.log('Navigate to first');
}
navigateToLast() {
console.log('Navigate to last');
}
close() {
console.log('Widget closed');
}
handleClick(e) {
console.log('Clicked:', e.target);
}
handleTouch(e) {
console.log('Touch:', e.target);
}
onFocus() {
this.element.classList.add('focused');
}
onBlur() {
this.element.classList.remove('focused');
}
handleTabInTrap(isShiftTab) {
// Implement focus trap logic
}
// Public API
destroy() {
// Clean up event listeners
this.element.removeAttribute('role');
this.element.removeAttribute('tabindex');
this.isInitialized = false;
}
}
// Usage
const widget = new CustomWidget(document.getElementById('my-widget'));
| Widget Checklist | ✓ Verified | Test Method |
|---|---|---|
| Keyboard Accessible | ☐ | Unplug mouse, navigate with keyboard only |
| Screen Reader Compatible | ☐ | Test with NVDA/JAWS/VoiceOver |
| Proper ARIA Roles | ☐ | Verify with browser DevTools |
| States Update Dynamically | ☐ | Check ARIA attributes change with state |
| Focus Visible | ☐ | Clear focus indicator on all elements |
| Focus Management | ☐ | Focus moves logically through widget |
| Touch Targets ≥44px | ☐ | Test on mobile device |
| Works Without JS | ☐ | Progressive enhancement (if possible) |
| Respects User Preferences | ☐ | Test prefers-reduced-motion, color schemes |
| Error States Announced | ☐ | Use aria-live or role="alert" |
| Cross-Browser Tested | ☐ | Chrome, Firefox, Safari, Edge |
| Mobile AT Tested | ☐ | iOS VoiceOver, Android TalkBack |
| Common Widget Types | ARIA Pattern Reference | Complexity |
|---|---|---|
| Accordion | button[aria-expanded] + region |
Low |
| Tabs | tablist + tab + tabpanel |
Medium |
| Dialog/Modal | dialog + focus trap |
Medium |
| Dropdown Menu | menu + menuitem |
Medium |
| Combobox | combobox + listbox + active descendant |
High |
| Slider | slider + aria-valuemin/max/now |
Medium |
| Data Grid | grid + row + gridcell |
High |
| Tree View | tree + treeitem + nested structure |
High |
| Tooltip | tooltip + aria-describedby |
Low |
| Carousel | region + group + rotation controls |
Medium-High |
Warning: Custom widgets are difficult to get right. Before building custom, ask: Can native HTML solve this? Then: Can existing library solve
this? Only build custom when absolutely necessary.
Resources:
- ARIA Authoring Practices Guide (APG): w3.org/WAI/ARIA/apg/ - Definitive patterns for all widgets
- Inclusive Components: inclusive-components.design - Real-world accessible component patterns
- a11y-dialog: github.com/KittyGiraudel/a11y-dialog - Reference modal implementation
- Reach UI: reach.tech - Accessible React component library to study
Interactive Components Quick Reference
- Always use
<button>for buttons - addtype="button"to prevent form submission - Toggle buttons need
aria-pressed, menu buttons needaria-expanded - Modals require:
role="dialog",aria-modal="true", focus trap, Escape to close - Focus first element on modal open, restore focus on close
- Use native
<select>or<datalist>instead of custom combobox when possible - Dropdown menus use
role="menu"with arrow key navigation - Tabs: roving tabindex, Left/Right arrow navigation, automatic activation recommended
- Prefer
<details>/<summary>for accordions - native and accessible - Custom widgets must implement keyboard interaction per ARIA Authoring Practices Guide
- Test all widgets with keyboard only and multiple screen readers