ARIA & Semantic HTML: Making Web Apps Understandable
ARIA is powerful but complex - understand when and how to use it correctly.
Quick Navigation: Semantic HTML First ⢠ARIA Roles ⢠ARIA States & Properties ⢠Complex Widget Patterns ⢠Common Mistakes & Best Practices
Quick Decision Guide
ARIA Rules:
1. Prefer semantic HTML: <button> not <div role="button">
2. Use ARIA for complex widgets: Tabs, accordions, modals, dropdowns
3. Key attributes: - role: Element type (button, dialog, tab) - aria-label: Accessible name - aria-expanded: Open/closed state - aria-hidden: Hide from screen readers
4. Always test with screen readers
Result: Custom components work for keyboard and screen reader users.
Semantic HTML First
Use Native Elements
<!-- ā
Best: Native button -->
<button onClick={handleClick}>Submit</button>
<!-- ā ļø OK: ARIA button (only if necessary) -->
<div
role="button"
tabindex="0"
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
Submit
</div>
<!-- ā Bad: No accessibility -->
<div onClick={handleClick}>Submit</div>Native Semantics
<!-- These have built-in accessibility -->
<button> <!-- Keyboard, role, states -->
<a href=""> <!-- Keyboard, role -->
<input> <!-- Keyboard, role, label association -->
<select> <!-- Keyboard, role, native dropdown -->
<textarea> <!-- Keyboard, role -->
<!-- Semantic structure -->
<header>
<nav>
<main>
<article>
<section>
<aside>
<footer>When to Use ARIA
Only when HTML doesn't provide needed semantics:
ARIA Roles
Widget Roles
<!-- Button -->
<div role="button" tabindex="0">Click me</div>
<!-- Checkbox -->
<div role="checkbox" aria-checked="false" tabindex="0">
Accept terms
</div>
<!-- Radio group -->
<div role="radiogroup">
<div role="radio" aria-checked="true" tabindex="0">Option 1</div>
<div role="radio" aria-checked="false" tabindex="-1">Option 2</div>
</div>
<!-- Slider -->
<div
role="slider"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="50"
aria-label="Volume"
tabindex="0"
>
<div style="width: 50%"></div>
</div>Composite Roles
// Tabs
function Tabs({ tabs }) {
const [selected, setSelected] = useState(0);
return (
<div>
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={selected === i}
aria-controls={`panel-${i}`}
onClick={() => setSelected(i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={i}
id={`panel-${i}`}
role="tabpanel"
hidden={selected !== i}
>
{tab.content}
</div>
))}
</div>
);
}
// Accordion
<div>
<h3>
<button
aria-expanded={isOpen}
aria-controls="content-1"
>
Section 1
</button>
</h3>
<div id="content-1" role="region" hidden={!isOpen}>
Content
</div>
</div>
// Menu
<div role="menu">
<div role="menuitem" tabindex="0">New</div>
<div role="menuitem" tabindex="-1">Open</div>
<div role="separator"></div>
<div role="menuitem" tabindex="-1">Save</div>
</div>Document Structure Roles
<div role="banner">Header</div>
<div role="navigation">Nav</div>
<div role="main">Main content</div>
<div role="complementary">Sidebar</div>
<div role="contentinfo">Footer</div>
<div role="search">Search form</div>ARIA States & Properties
Labels
<!-- aria-label: Direct label -->
<button aria-label="Close dialog">Ć</button>
<!-- aria-labelledby: Reference to label element -->
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Deletion</h2>
</div>
<!-- aria-describedby: Additional description -->
<input
aria-label="Password"
aria-describedby="password-rules"
/>
<div id="password-rules">
Must be at least 8 characters
</div>States
// Expanded/collapsed
<button
aria-expanded={isOpen}
aria-controls="dropdown-menu"
>
Menu
</button>
<div id="dropdown-menu" hidden={!isOpen}>...</div>
// Pressed (toggle buttons)
<button
aria-pressed={isBold}
onClick={() => setIsBold(!isBold)}
>
<BoldIcon /> Bold
</button>
// Selected
<div role="option" aria-selected={isSelected}>
Option 1
</div>
// Checked
<div role="checkbox" aria-checked={isChecked}>
Accept terms
</div>
// Disabled
<button aria-disabled="true" disabled>
Submit
</button>
// Invalid
<input
aria-invalid={hasError}
aria-errormessage="error-msg"
/>
<div id="error-msg" role="alert">{error}</div>Live Regions
// Alert (interrupts)
<div role="alert">
Form submitted successfully!
</div>
// Status (polite, doesn't interrupt)
<div role="status" aria-live="polite">
Loading...
</div>
// Log (chat messages, etc.)
<div role="log" aria-live="polite" aria-atomic="false">
<div>New message from John</div>
</div>
// Timer
<div role="timer" aria-live="off">
Time remaining: 5:30
</div>Complex Widget Patterns
Modal Dialog
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
>
Ć
</button>
{children}
</div>
<div
className="modal-overlay"
onClick={onClose}
aria-hidden="true"
/>
</div>
);
}Combobox (Autocomplete)
function Combobox({ options, value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
return (
<div>
<input
role="combobox"
aria-expanded={isOpen}
aria-controls="listbox"
aria-activedescendant={
activeIndex >= 0 ? `option-${activeIndex}` : undefined
}
value={value}
onChange={(e) => {
onChange(e.target.value);
setIsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(Math.min(activeIndex + 1, options.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(Math.max(activeIndex - 1, 0));
} else if (e.key === 'Enter' && activeIndex >= 0) {
onChange(options[activeIndex]);
setIsOpen(false);
}
}}
/>
{isOpen && (
<ul id="listbox" role="listbox">
{options.map((option, i) => (
<li
key={i}
id={`option-${i}`}
role="option"
aria-selected={i === activeIndex}
onClick={() => {
onChange(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}Common Mistakes & Best Practices
Mistakes to Avoid
<!-- ā Overriding native semantics -->
<button role="heading">This is confusing</button>
<!-- ā Missing required attributes -->
<div role="button">No tabindex or keyboard support</div>
<!-- ā Redundant ARIA -->
<button role="button">Already a button</button>
<!-- ā Incorrect states -->
<button aria-expanded="yes">Should be true/false</button>
<!-- ā Missing label -->
<div role="dialog">
<h2>No aria-labelledby</h2>
</div>Best Practices
<!-- ā
Proper role with required attributes -->
<div
role="button"
tabindex="0"
onKeyDown={handleKey}
>
Custom button
</div>
<!-- ā
Modal with proper ARIA -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="title"
>
<h2 id="title">Dialog Title</h2>
</div>
<!-- ā
Form with error -->
<input
aria-invalid="true"
aria-describedby="error"
/>
<div id="error" role="alert">Invalid email</div>
<!-- ā
Loading state -->
<button aria-busy="true" disabled>
<Spinner /> Loading...
</button>Testing Checklist
ā Use semantic HTML when possible
ā All custom widgets have proper roles
ā All interactive elements are keyboard accessible
ā ARIA states update dynamically
ā Test with screen reader (VoiceOver/NVDA)
ā Run axe DevTools audit
ā Check ARIA specification for patterns