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
<!-- 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
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 : 500 px ;
width : 90 % ;
border-radius : 8 px ;
box-shadow : 0 4 px 20 px rgba ( 0 , 0 , 0 , 0.3 );
z-index : 1 ;
}
.modal-header {
display : flex ;
justify-content : space-between ;
align-items : center ;
padding : 16 px 20 px ;
border-bottom : 1 px solid #e0e0e0 ;
}
.close-btn {
background : none ;
border : none ;
font-size : 28 px ;
cursor : pointer ;
padding : 0 ;
width : 32 px ;
height : 32 px ;
}
.modal-body {
padding : 20 px ;
}
.modal-footer {
padding : 16 px 20 px ;
border-top : 1 px solid #e0e0e0 ;
display : flex ;
justify-content : flex-end ;
gap : 8 px ;
}
</ 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
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
<!-- 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
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 : 1 px solid #ddd ;
border-radius : 4 px ;
}
[ role = "tablist" ] {
display : flex ;
border-bottom : 2 px solid #ddd ;
background : #f5f5f5 ;
}
[ role = "tab" ] {
padding : 12 px 24 px ;
border : none ;
background : transparent ;
cursor : pointer ;
position : relative ;
transition : all 0.2 s ;
}
[ role = "tab" ] :hover {
background : #e0e0e0 ;
}
[ role = "tab" ] :focus {
outline : 2 px solid #0066cc ;
outline-offset : -2 px ;
z-index : 1 ;
}
[ role = "tab" ][ aria-selected = "true" ] {
background : white ;
border-bottom : 3 px solid #0066cc ;
font-weight : 600 ;
color : #0066cc ;
}
[ role = "tabpanel" ] {
padding : 20 px ;
}
[ role = "tabpanel" ] :focus {
outline : 2 px solid #0066cc ;
outline-offset : -2 px ;
}
</ 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.
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 : 1 px solid #ddd ;
border-radius : 4 px ;
padding : 12 px ;
margin-bottom : 8 px ;
}
summary {
cursor : pointer ;
font-weight : 600 ;
padding : 8 px 0 ;
list-style : none ; /* Remove default marker */
display : flex ;
align-items : center ;
}
/* Custom marker */
summary ::before {
content : "▶" ;
margin-right : 8 px ;
transition : transform 0.2 s ;
display : inline-block ;
}
details [ open ] summary ::before {
transform : rotate ( 90 deg );
}
/* Remove default marker in WebKit */
summary ::-webkit-details-marker {
display : none ;
}
summary :focus {
outline : 2 px solid #0066cc ;
outline-offset : 2 px ;
border-radius : 2 px ;
}
details p {
margin-top : 12 px ;
padding-left : 24 px ;
}
</ 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 : 1 px solid #ddd ;
border-radius : 4 px ;
}
.accordion h3 {
margin : 0 ;
border-bottom : 1 px solid #ddd ;
}
.accordion h3 :last-of-type {
border-bottom : none ;
}
.accordion-trigger {
width : 100 % ;
padding : 16 px ;
border : none ;
background : white ;
text-align : left ;
cursor : pointer ;
display : flex ;
justify-content : space-between ;
align-items : center ;
font-size : 16 px ;
transition : background 0.2 s ;
}
.accordion-trigger:hover {
background : #f5f5f5 ;
}
.accordion-trigger:focus {
outline : 2 px solid #0066cc ;
outline-offset : -2 px ;
z-index : 1 ;
}
.accordion-trigger [ aria-expanded = "true" ] {
background : #f0f8ff ;
}
.accordion-icon {
font-size : 24 px ;
font-weight : bold ;
transition : transform 0.2 s ;
}
.accordion-trigger [ aria-expanded = "true" ] .accordion-icon {
transform : rotate ( 45 deg );
}
.accordion-panel {
padding : 16 px ;
border-top : 1 px 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.
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
// 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 - add type="button" to prevent form
submission
Toggle buttons need aria-pressed, menu buttons need aria-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