|$ curl https://forge-ai.dev/api/markdown?path=docs/js/accessible-components
$cat docs/javascript-—-accessible-interactive-components.md
updated Recently·45 min read·published

JavaScript — Accessible Interactive Components

JavaScriptAccessibilityARIAKeyboardWCAGAdvanced🎯Free Tools
Introduction

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities. Approximately 15% of the global population — over one billion people — experience some form of disability. This includes visual impairments (blindness, low vision, color blindness), motor disabilities (limited fine motor control, paralysis), auditory disabilities (deafness, hard of hearing), and cognitive disabilities (learning disabilities, attention disorders, memory impairments).

The Web Content Accessibility Guidelines (WCAG) provide the international standard for web accessibility, organized around four core principles — POUR: Perceivable (information must be presentable in ways users can perceive), Operable (UI components must be operable via keyboard, mouse, touch, and assistive technology), Understandable (information and UI operation must be understandable), and Robust (content must be robust enough to be interpreted by assistive technologies).

The foundation of accessible web development is semantic HTML. Using the right HTML elements for the right purpose provides accessibility features for free. A <button> is focusable, operable via keyboard, announced correctly by screen readers, and has a click handler — all without any JavaScript. A <div onClick={...}> has none of these properties. Always prefer native HTML elements before reaching for custom implementations.

When custom interactive components are necessary, JavaScript becomes the tool to add the missing accessibility semantics. This page covers the patterns, techniques, and ARIA attributes needed to build truly accessible interactive components — from keyboard navigation and focus management to screen reader announcements and dynamic content updates.

semantic-html.html
HTML
1<!-- BAD — div pretending to be a button -->
2<div class="btn" onclick="submitForm()">Submit</div>
3<!-- Not focusable, no keyboard support, no role announced -->
4
5<!-- GOOD — native button element -->
6<button type="submit">Submit</button>
7<!-- Focusable, keyboard operable, announced as "Submit, button" -->
8
9<!-- BAD — span acting as a checkbox -->
10<span class="checkbox" onclick="toggle(this)">
11 <span class="check-icon">✓</span> Accept terms
12</span>
13
14<!-- GOOD — native checkbox with label -->
15<label>
16 <input type="checkbox" name="terms" />
17 Accept terms
18</label>
19
20<!-- BAD — generic div for navigation -->
21<div class="nav-item" onclick="goTo('/page')">Home</div>
22
23<!-- GOOD — semantic anchor element -->
24<nav aria-label="Main navigation">
25 <a href="/page" aria-current="page">Home</a>
26</nav>

best practice

Progressive enhancement is the accessibility-first strategy: build with semantic HTML first (accessible by default), layer on CSS for visual design, then add JavaScript for enhanced interactivity. This ensures your core content and functionality remain accessible even if JavaScript fails to load.
Keyboard Navigation

Keyboard accessibility is the most fundamental aspect of interactive accessibility. Many users rely on keyboards exclusively — people with motor disabilities, power users, and screen reader users all navigate primarily via keyboard. Every interactive element must be reachable and operable with keyboard alone.

The browser provides a natural focus order based on the DOM sequence. Elements that are naturally focusable include links, buttons, form inputs, and elements with tabindex. The tabindex attribute controls how elements participate in keyboard navigation: tabindex="0" adds an element to the natural tab order, tabindex="-1" makes an element focusable only programmatically (not via Tab), and positive values (e.g., tabindex="5") override natural order — which causes unpredictable behavior and should be avoided.

For composite widgets like toolbars, menus, tab lists, and listboxes, the roving tabindex pattern is the standard approach. Only one item in the group has tabindex="0" (the currently active item), while all other items have tabindex="-1". Arrow keys move focus between items, and the active item is updated accordingly. This keeps the Tab key reserved for moving between widget groups, not individual items within them.

KeyActionContext
TabMove to next focusable elementGeneral navigation between groups
Shift+TabMove to previous focusable elementGeneral navigation between groups
EnterActivate focused elementButtons, links, checkboxes
SpaceActivate or toggle focused elementButtons, checkboxes, toggles
Arrow KeysNavigate within composite widgetsTabs, menus, toolbars, listboxes
EscapeClose popup, dismiss dialogModals, dropdowns, menus
HomeMove to first itemTab lists, menus, listboxes
EndMove to last itemTab lists, menus, listboxes
roving-tabindex.js
JavaScript
1// Roving tabindex pattern for a toolbar or tab list
2function setupRovingTabindex(containerSelector, itemSelector) {
3 const container = document.querySelector(containerSelector);
4 const items = Array.from(container.querySelectorAll(itemSelector));
5 let activeIndex = 0;
6
7 // Set initial tabindex values
8 items.forEach((item, i) => {
9 item.setAttribute('tabindex', i === 0 ? '0' : '-1');
10 });
11
12 // Handle arrow key navigation
13 container.addEventListener('keydown', (e) => {
14 let newIndex = activeIndex;
15
16 switch (e.key) {
17 case 'ArrowRight':
18 case 'ArrowDown':
19 e.preventDefault();
20 newIndex = (activeIndex + 1) % items.length;
21 break;
22 case 'ArrowLeft':
23 case 'ArrowUp':
24 e.preventDefault();
25 newIndex = (activeIndex - 1 + items.length) % items.length;
26 break;
27 case 'Home':
28 e.preventDefault();
29 newIndex = 0;
30 break;
31 case 'End':
32 e.preventDefault();
33 newIndex = items.length - 1;
34 break;
35 default:
36 return;
37 }
38
39 // Move focus to new item
40 items[activeIndex].setAttribute('tabindex', '-1');
41 items[newIndex].setAttribute('tabindex', '0');
42 items[newIndex].focus();
43 activeIndex = newIndex;
44 });
45}
46
47// Usage
48setupRovingTabindex('[role="tablist"]', '[role="tab"]');
preview

info

Never use positive tabindex values (e.g., tabindex="3"). They create a separate tab order that overrides the natural DOM sequence, resulting in confusing, unpredictable navigation. Use tabindex="0" to add elements to the natural order and DOM reordering to change focus sequence.
Focus Management

Focus management goes beyond tab order — it involves programmatically controlling where keyboard focus goes, trapping focus within modal regions, restoring focus when modals close, and providing skip links for efficient navigation. Poor focus management is one of the most common accessibility failures in complex web applications.

The element.focus() method moves keyboard focus to an element, and element.blur() removes focus. When using focus(), ensure the element is visible, scrollable into view if needed, and that the focus indicator is visible. The :focus-visible CSS pseudo-class applies focus styles only when the browser determines the user is navigating via keyboard (not mouse clicks), which is the modern best practice for focus styling.

Focus trapping is essential for modals and dialogs. When a modal opens, focus must move into the modal and cycle through its focusable elements only — Tab from the last element should wrap to the first, and Shift+Tab from the first should wrap to the last. When the modal closes, focus must return to the element that triggered it. This prevents keyboard users from accidentally interacting with content behind the modal.

Skip links are hidden links at the top of the page that become visible on keyboard focus, allowing users to jump directly to main content, navigation, or search — bypassing repetitive header content. They are essential for users who navigate primarily via keyboard.

focus-trap.js
JavaScript
1// Focus trapping in a modal dialog
2function trapFocus(modalElement) {
3 const focusableSelector = [
4 'a[href]', 'button:not([disabled])', 'input:not([disabled])',
5 'select:not([disabled])', 'textarea:not([disabled])',
6 '[tabindex]:not([tabindex="-1"])'
7 ].join(', ');
8
9 const focusableElements = Array.from(
10 modalElement.querySelectorAll(focusableSelector)
11 );
12
13 if (focusableElements.length === 0) return;
14
15 const firstFocusable = focusableElements[0];
16 const lastFocusable = focusableElements[focusableElements.length - 1];
17
18 // Move focus into the modal
19 firstFocusable.focus();
20
21 // Handle Tab key to trap focus
22 modalElement.addEventListener('keydown', (e) => {
23 if (e.key !== 'Tab') return;
24
25 if (e.shiftKey) {
26 // Shift+Tab: if on first element, wrap to last
27 if (document.activeElement === firstFocusable) {
28 e.preventDefault();
29 lastFocusable.focus();
30 }
31 } else {
32 // Tab: if on last element, wrap to first
33 if (document.activeElement === lastFocusable) {
34 e.preventDefault();
35 firstFocusable.focus();
36 }
37 }
38 });
39}
40
41// Open modal with focus management
42function openModal(modalElement, triggerElement) {
43 // Store the trigger for focus return later
44 modalElement._triggerElement = triggerElement;
45
46 // Show the modal
47 modalElement.hidden = false;
48 modalElement.setAttribute('aria-hidden', 'false');
49
50 // Prevent background scrolling
51 document.body.setAttribute('aria-hidden', 'true');
52
53 // Trap focus inside modal
54 trapFocus(modalElement);
55}
56
57// Close modal and restore focus
58function closeModal(modalElement) {
59 modalElement.hidden = true;
60 modalElement.setAttribute('aria-hidden', 'true');
61 document.body.removeAttribute('aria-hidden');
62
63 // Return focus to the trigger element
64 if (modalElement._triggerElement) {
65 modalElement._triggerElement.focus();
66 }
67}
skip-link.html
HTML
1<!-- Skip link — first focusable element in the page -->
2<a href="#main-content" class="skip-link">
3 Skip to main content
4</a>
5
6<!-- CSS for skip link (hidden until focused) -->
7<style>
8.skip-link {
9 position: absolute;
10 top: -100%;
11 left: 16px;
12 background: #00FF41;
13 color: #0A0A0A;
14 padding: 8px 16px;
15 z-index: 10000;
16 font-family: monospace;
17 font-size: 12px;
18 border-radius: 4px;
19 text-decoration: none;
20}
21.skip-link:focus {
22 top: 16px; /* Visible when focused via keyboard */
23}
24</style>
25
26<!-- Main content landmark -->
27<main id="main-content" tabindex="-1">
28 <h1>Page Title</h1>
29 <!-- Content goes here -->
30</main>
preview
ARIA Roles & Properties

ARIA (Accessible Rich Internet Applications) defines attributes that supplement HTML semantics for cases where native elements cannot convey the required meaning. ARIA has three categories: roles (defining what an element is or does), properties (defining additional characteristics), and states (defining current conditions of an element).

The first rule of ARIA is: do not use ARIA if you can use a native HTML element with the semantics and behavior you require. Native elements provide accessibility for free — a <button> already has role="button", keyboard handling, focus management, and form submission. ARIA should only fill gaps where HTML falls short.

Labeling Attributes

AttributeDescriptionExample
aria-labelProvides an accessible name when visible text is insufficientaria-label="Close dialog"
aria-labelledbyReferences another element's text as the accessible namearia-labelledby="title-element"
aria-describedbyReferences additional descriptive text (announced after the label)aria-describedby="error-msg"

Common ARIA Roles

RoleElementRequired Children
dialogModal or inline dialog
alertdialogDialog requiring user response
tablistContainer for tab elements[role="tab"]
tabSelectable tab button
tabpanelContent panel associated with tab
menuMenu of choices or actions[role="menuitem"]
menuitemSelectable option in a menu
listboxList of selectable options[role="option"]
optionSelectable item in a listbox
treeHierarchical list with expandable items[role="treeitem"]
toolbarCollection of related controls
alertImportant, time-sensitive message
statusAdvisory, non-urgent message
progressbarIndicator of progress completion
tabSelectable tab button

State & Property Attributes

AttributeTypeDescription
aria-expandedStateWhether a collapsible section is open (true/false)
aria-hiddenStateWhether an element is hidden from accessibility tree
aria-selectedStateWhether an item in a list/tablist is currently selected
aria-checkedStateCheckbox/radio state: true, false, or mixed
aria-disabledStateElement is perceivable but not operable
aria-invalidStateValue does not conform to expected format
aria-requiredPropertyInput field is required before form submission
aria-livePropertyRegion announced on dynamic updates: polite, assertive, off
aria-controlsPropertyReferences the element controlled by this element
aria-ownsPropertyDeclares a parent-child relationship not in DOM
aria-activedescendantPropertyIdentifies the currently active descendant in a composite widget
aria-examples.html
HTML
1<!-- ARIA labeling examples -->
2<button aria-label="Close dialog">
3 <svg aria-hidden="true"><!-- X icon --></svg>
4</button>
5
6<h3 id="dialog-title">Confirm Deletion</h3>
7<div role="dialog" aria-labelledby="dialog-title" aria-describedby="dialog-desc">
8 <p id="dialog-desc">This action cannot be undone.</p>
9</div>
10
11<!-- ARIA state changes with JavaScript -->
12<button
13 aria-expanded="false"
14 aria-controls="dropdown-menu"
15 id="menu-button"
16>
17 Options
18</button>
19<ul id="dropdown-menu" role="menu" hidden>
20 <li role="menuitem" tabindex="-1">Edit</li>
21 <li role="menuitem" tabindex="-1">Delete</li>
22</ul>
23
24<!-- aria-hidden for decorative icons -->
25<span aria-hidden="true">🏠</span> Home
26<span aria-hidden="true">⚙️</span> Settings

warning

Do not use aria-hidden="true" on focusable elements. A hidden element that receives focus creates a confusing experience — the element is invisible to screen readers but still receives keyboard focus. If you need to hide an element, also remove it from the tab order with tabindex="-1" or disable it.
Live Regions (aria-live)

Live regions are the mechanism by which JavaScript can announce dynamic content changes to screen readers. Without live regions, updates to the DOM (like error messages, search results, or status updates) would be invisible to assistive technology users. Live regions define areas of the page that will be monitored for changes.

The aria-live attribute has three values: "polite" announces changes when the screen reader is idle (non-urgent updates like search results count), "assertive" interrupts the current announcement to deliver the message immediately (error messages, urgent alerts), and "off" (default) does not announce changes. Use assertive sparingly — it interrupts whatever the user is currently doing.

Built-in roles like role="status" (equivalent to aria-live="polite" + aria-atomic="true") and role="alert" (equivalent to aria-live="assertive" + role="alert") provide semantic shortcuts for common live region patterns. When content inside these elements changes, the screen reader announces it automatically.

PatternUsageUrgency
aria-live="polite"Search results count, form progress, timer updatesLow — waits for idle
aria-live="assertive"Error messages, critical alerts, session timeoutHigh — interrupts current speech
role="status"Status messages, loading indicators, save confirmationsLow — polite + atomic
role="alert"Error notifications, validation failures, warningsHigh — assertive + atomic
role="log"Chat messages, activity feeds, event logsLow — newest additions announced
preview

info

Live regions must exist in the DOM before their content is updated. Creating a live region element and adding content simultaneously in a single operation may not trigger an announcement in some screen readers. Pre-render the live region element (even if empty) and then update its content programmatically.
Accessible Modal / Dialog

Modals and dialogs are among the most accessibility-challenging components. They require correct ARIA roles, focus management, keyboard handling, focus trapping, focus return, and proper interaction with the background content. The HTML <dialog> element provides native semantics and built-in showModal() / close() methods with a native ::backdrop pseudo-element.

An accessible modal must: use role="dialog" and aria-modal="true", have an accessible name via aria-labelledby, trap keyboard focus within the modal, close on Escape key, return focus to the trigger element on close, and prevent interaction with background content (via inert attribute or aria-hidden on the main content).

dialog-element.html
HTML
1<!-- Native <dialog> element — most accessible approach -->
2<dialog id="confirm-dialog" aria-labelledby="dialog-title">
3 <h2 id="dialog-title">Confirm Deletion</h2>
4 <p>Are you sure you want to delete this item? This action cannot be undone.</p>
5 <div class="dialog-actions">
6 <button id="dialog-cancel">Cancel</button>
7 <button id="dialog-confirm">Delete</button>
8 </div>
9</dialog>
dialog-patterns.js
JavaScript
1// Accessible modal using native <dialog> element
2const dialog = document.getElementById('confirm-dialog');
3const openBtn = document.getElementById('open-dialog');
4const cancelBtn = document.getElementById('dialog-cancel');
5const confirmBtn = document.getElementById('dialog-confirm');
6
7// Open modal — focus moves into it automatically
8openBtn.addEventListener('click', () => {
9 dialog.showModal(); // Adds backdrop, traps focus, handles Escape
10});
11
12// Close on Cancel button or Escape key (built-in)
13cancelBtn.addEventListener('click', () => {
14 dialog.close('cancel');
15});
16
17// Handle confirmation
18confirmBtn.addEventListener('click', () => {
19 dialog.close('confirm');
20});
21
22// Handle the result after close
23dialog.addEventListener('close', () => {
24 if (dialog.returnValue === 'confirm') {
25 deleteItem();
26 }
27 // Focus automatically returns to the element that had focus
28 // before showModal() was called
29});
30
31// For custom dialogs without <dialog>, use this pattern:
32function openCustomModal(modal, trigger) {
33 modal._trigger = trigger; // Store trigger for focus return
34 modal.hidden = false;
35 modal.setAttribute('aria-hidden', 'false');
36
37 // Make background content inert
38 const main = document.getElementById('main-content');
39 main.setAttribute('inert', '');
40 main.setAttribute('aria-hidden', 'true');
41
42 // Focus first focusable element in modal
43 const first = modal.querySelector(
44 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
45 );
46 if (first) first.focus();
47}
48
49function closeCustomModal(modal) {
50 modal.hidden = true;
51 modal.setAttribute('aria-hidden', 'true');
52
53 // Remove inert from background
54 const main = document.getElementById('main-content');
55 main.removeAttribute('inert');
56 main.removeAttribute('aria-hidden');
57
58 // Return focus to trigger
59 if (modal._trigger) modal._trigger.focus();
60}
preview
Accessible Dropdown / Menu

Dropdown menus are ubiquitous but frequently implemented without accessibility support. An accessible dropdown requires: a trigger button with aria-expanded, a menu container with role="menu", items with role="menuitem", arrow key navigation within the menu, Escape to close, and Home/End keys to jump to first/last items.

When the menu opens, focus should move to the first (or currently selected) menu item. Arrow keys move focus between items, wrapping at the ends. Typing a character should jump to the next item starting with that character. When the menu closes, focus returns to the trigger button, and aria-expanded is set to false.

accessible-menu.js
JavaScript
1// Accessible dropdown menu pattern
2class AccessibleMenu {
3 constructor(triggerEl, menuEl) {
4 this.trigger = triggerEl;
5 this.menu = menuEl;
6 this.items = Array.from(menuEl.querySelectorAll('[role="menuitem"]'));
7 this.isOpen = false;
8 this.activeIndex = 0;
9
10 this.trigger.setAttribute('aria-haspopup', 'true');
11 this.trigger.setAttribute('aria-expanded', 'false');
12
13 this.trigger.addEventListener('click', () => this.toggle());
14 this.trigger.addEventListener('keydown', (e) => {
15 if (['Enter', ' ', 'ArrowDown'].includes(e.key)) {
16 e.preventDefault();
17 this.open();
18 }
19 });
20
21 this.menu.addEventListener('keydown', (e) => this.handleMenuKeydown(e));
22
23 // Close on outside click
24 document.addEventListener('click', (e) => {
25 if (!this.trigger.contains(e.target) && !this.menu.contains(e.target)) {
26 this.close();
27 }
28 });
29 }
30
31 toggle() { this.isOpen ? this.close() : this.open(); }
32
33 open() {
34 this.isOpen = true;
35 this.menu.hidden = false;
36 this.trigger.setAttribute('aria-expanded', 'true');
37 this.setActive(0);
38 }
39
40 close(returnFocus = true) {
41 this.isOpen = false;
42 this.menu.hidden = true;
43 this.trigger.setAttribute('aria-expanded', 'false');
44 if (returnFocus) this.trigger.focus();
45 }
46
47 setActive(index) {
48 this.items.forEach((item, i) => {
49 item.setAttribute('tabindex', i === index ? '0' : '-1');
50 });
51 this.items[index].focus();
52 this.activeIndex = index;
53 }
54
55 handleMenuKeydown(e) {
56 switch (e.key) {
57 case 'ArrowDown':
58 e.preventDefault();
59 this.setActive((this.activeIndex + 1) % this.items.length);
60 break;
61 case 'ArrowUp':
62 e.preventDefault();
63 this.setActive((this.activeIndex - 1 + this.items.length) % this.items.length);
64 break;
65 case 'Home':
66 e.preventDefault();
67 this.setActive(0);
68 break;
69 case 'End':
70 e.preventDefault();
71 this.setActive(this.items.length - 1);
72 break;
73 case 'Escape':
74 e.preventDefault();
75 this.close();
76 break;
77 case 'Tab':
78 this.close(false);
79 break;
80 }
81 }
82}
preview
Accessible Tabs

Tab interfaces are one of the most common composite widgets on the web. An accessible tab component uses role="tablist" on the container, role="tab" on each tab button, and role="tabpanel" on each content panel. Each tab must have aria-selected (true on the active tab, false on others), aria-controls referencing its panel, and id matching its panel's aria-labelledby.

Keyboard interaction follows the roving tabindex pattern: the active tab has tabindex="0", all others have tabindex="-1". Left/Right arrow keys (or Up/Down for vertical tabs) move between tabs. Home/End jump to the first/last tab. Tab key moves focus out of the tablist into the active panel content. Pressing Enter or Space on a tab can optionally activate it (though some guidelines prefer arrow-only activation).

accessible-tabs.js
JavaScript
1// Accessible Tabs component with roving tabindex
2class AccessibleTabs {
3 constructor(tablistEl) {
4 this.tablist = tablistEl;
5 this.tabs = Array.from(tablistEl.querySelectorAll('[role="tab"]'));
6 this.panels = this.tabs.map(tab =>
7 document.getElementById(tab.getAttribute('aria-controls'))
8 );
9 this.activeIndex = this.tabs.findIndex(
10 tab => tab.getAttribute('aria-selected') === 'true'
11 );
12
13 this.tabs.forEach((tab, i) => {
14 tab.addEventListener('click', () => this.activate(i));
15 tab.addEventListener('keydown', (e) => this.handleKeydown(e, i));
16 });
17 }
18
19 activate(index) {
20 this.tabs.forEach((tab, i) => {
21 const isActive = i === index;
22 tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
23 tab.setAttribute('tabindex', isActive ? '0' : '-1');
24 this.panels[i].hidden = !isActive;
25 });
26 this.tabs[index].focus();
27 this.activeIndex = index;
28 }
29
30 handleKeydown(e, currentIndex) {
31 let newIndex = currentIndex;
32
33 switch (e.key) {
34 case 'ArrowRight': newIndex = (currentIndex + 1) % this.tabs.length; break;
35 case 'ArrowLeft': newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length; break;
36 case 'Home': newIndex = 0; break;
37 case 'End': newIndex = this.tabs.length - 1; break;
38 default: return;
39 }
40
41 e.preventDefault();
42 this.activate(newIndex);
43 }
44}
45
46// Usage
47const tabs = new AccessibleTabs(document.querySelector('[role="tablist"]'));
Accessible Accordion

An accordion is a vertically stacked set of headers that each expand or collapse a content panel. The ARIA accordion pattern uses a header button with aria-expanded and aria-controls linking to the panel's id. Pressing Enter or Space on the header toggles the panel. The panel should use role="region" with an aria-labelledby referencing the header button.

Keyboard navigation: Tab moves between accordion headers. Enter or Space toggles the expanded/collapsed state. In some implementations, Up/Down arrows move between headers. Each header must be a <button> element — not a <div> or <h3> with a click handler. Using a native button provides keyboard support and screen reader semantics.

preview
Accessible Form Validation

Form validation is critical for both usability and accessibility. Error messages must be programmatically associated with their respective fields using aria-describedby, invalid fields must be marked with aria-invalid="true", and an error summary at the top of the form should provide a quick overview with links to each invalid field.

Validation errors should be announced to screen readers using a live region. The error summary should be wrapped in a role="alert" or aria-live="assertive" container so it is announced immediately when it appears. Each error message within the summary should be a link that moves focus to the corresponding field.

form-validation.js
JavaScript
1// Accessible form validation with error associations
2function validateForm(formElement) {
3 const errors = [];
4 const fields = formElement.querySelectorAll('input, select, textarea');
5
6 // Clear previous errors
7 fields.forEach(field => {
8 field.removeAttribute('aria-invalid');
9 const errorId = field.id + '-error';
10 const errorEl = document.getElementById(errorId);
11 if (errorEl) errorEl.textContent = '';
12 });
13
14 // Validate each field
15 fields.forEach(field => {
16 const errorId = field.id + '-error';
17 const errorEl = document.getElementById(errorId);
18 let message = '';
19
20 if (field.required && !field.value.trim()) {
21 message = field.getAttribute('data-label') + ' is required';
22 } else if (field.type === 'email' && field.value && !/^[^\s@]+@[^^\s@]+\.[^\s@]+$/.test(field.value)) {
23 message = 'Please enter a valid email address';
24 } else if (field.minLength > 0 && field.value.length < field.minLength) {
25 message = 'Must be at least ' + field.minLength + ' characters';
26 }
27
28 if (message) {
29 field.setAttribute('aria-invalid', 'true');
30 if (errorEl) errorEl.textContent = message;
31 errors.push({ field, message, id: field.id });
32 }
33 });
34
35 // Update error summary
36 const summary = document.getElementById('error-summary');
37 if (errors.length > 0) {
38 const list = errors.map(e =>
39 '<li><a href="#' + e.id + '">' + e.message + '</a></li>'
40 ).join('');
41 summary.innerHTML = '<p>' + errors.length + ' error(s) found:</p><ul>' + list + '</ul>';
42 summary.hidden = false;
43 summary.focus();
44
45 // Link error summary links to focus fields
46 summary.querySelectorAll('a').forEach((link, i) => {
47 link.addEventListener('click', (e) => {
48 e.preventDefault();
49 errors[i].field.focus();
50 });
51 });
52 } else {
53 summary.hidden = true;
54 // Submit the form
55 }
56}
preview

best practice

Always validate on the server too — client-side validation improves UX but can be bypassed. Use the HTML5 constraint validation API (field.checkValidity(), field.validationMessage) for built-in validation, and augment with custom ARIA for complex rules. Error messages should be specific and actionable — "Password must be at least 8 characters" is better than "Invalid input".
Color & Contrast

Color contrast is one of the most frequently failed WCAG criteria. WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18px+ or 14px+ bold). This applies to all text, icons, and interactive elements. The contrast ratio between foreground text and its background must meet these minimums.

Never use color alone to convey information. Red for errors, green for success, and blue for links may seem obvious to sighted users, but are invisible to colorblind users (approximately 8% of men and 0.5% of women). Always pair color with text labels, icons, patterns, or other visual indicators. Error states should have both a red border AND an error message icon AND text.

Focus indicators must also meet contrast requirements. The focus ring (outline) must have sufficient contrast against both the focused element and the background. The :focus-visible pseudo-class lets you style focus indicators only for keyboard navigation, avoiding the visual clutter of outlines on mouse clicks while maintaining keyboard accessibility.

ElementWCAG AA RatioWCAG AAA Ratio
Normal text (below 18px)4.5:17:1
Large text (18px+ or 14px+ bold)3:14.5:1
UI components and graphical objects3:1
Focus indicators3:1
Placeholder text4.5:1*7:1*
contrast-styles.css
CSS
1/* Focus visible — accessible focus indicator */
2button:focus-visible {
3 outline: 2px solid #00FF41;
4 outline-offset: 2px;
5}
6
7/* Never remove focus outlines without a visible replacement */
8button:focus {
9 /* BAD — removes focus indicator */
10 outline: none;
11}
12
13/* GOOD — provides focus indicator only for keyboard users */
14button:focus:not(:focus-visible) {
15 outline: none; /* Remove for mouse clicks */
16}
17button:focus-visible {
18 outline: 2px solid #00FF41;
19 outline-offset: 2px;
20}
21
22/* Color + text for error states (not color alone) */
23.field-error {
24 color: #EF4444;
25 border-left: 3px solid #EF4444;
26 padding-left: 8px;
27}
28
29/* Before: color-only error (BAD) */
30.field-error-bad {
31 color: red; /* Insufficient for colorblind users */
32}
33
34/* After: color + icon + text (GOOD) */
35.field-error-good {
36 color: #EF4444;
37}
38.field-error-good::before {
39 content: "⚠ "; /* Icon reinforces color */
40}
📝

note

Test your color contrast using browser DevTools (Chrome: Inspect element, look for contrast warning in the accessibility pane), the axe DevTools extension, or WebAIM's Contrast Checker at webaim.org/resources/contrastchecker. Most modern design systems include contrast-checking in their component documentation.
Screen Reader Testing

Automated accessibility tools catch only 30-40% of accessibility issues. Screen reader testing is essential for validating that your components are actually usable by blind and low-vision users. The three major screen readers are NVDA (Windows, free and open-source), VoiceOver (macOS and iOS, built-in), and TalkBack (Android, built-in).

Screen ReaderPlatformCostShortcut to Start
VoiceOvermacOS / iOSFree (built-in)Cmd + F5
NVDAWindowsFree (open-source)Ctrl + Alt + N
JAWSWindowsPaid ($95/year)Insert + F11
TalkBackAndroidFree (built-in)Hold volume keys
NarratorWindowsFree (built-in)Win + Ctrl + Enter

Screen Reader Testing Checklist

All images have meaningful alt text (or alt="" for decorative images)
Form inputs have associated labels (visible or aria-label)
Headings follow a logical hierarchy (h1 → h2 → h3, no skipped levels)
Interactive elements announce their name, role, and state
Dynamic content changes are announced via aria-live regions
Modal dialogs trap focus and announce their title on open
Error messages are associated with their fields and announced
Navigation landmarks (nav, main, aside) are present and labeled
Page title is descriptive and unique
Color is not the only means of conveying information

aria-hidden for Decorative Elements

Use aria-hidden="true" on elements that are purely decorative — SVG icons that are next to visible text labels, spacer images, decorative borders, and redundant content. This prevents screen readers from announcing irrelevant information. The element remains visible to sighted users but is completely hidden from assistive technology.

aria-hidden.html
HTML
1<!-- Decorative icon — aria-hidden -->
2<button>
3 <svg aria-hidden="true"><!-- trash icon --></svg>
4 Delete
5</button>
6
7<!-- Decorative image — empty alt attribute -->
8<img src="divider.png" alt="" />
9
10<!-- Redundant text hidden from screen readers -->
11<nav aria-label="Breadcrumb">
12 <ol>
13 <li><a href="/"><span aria-hidden="true">🏠</span><span class="sr-only">Home</span></a></li>
14 <li><a href="/docs"><span aria-hidden="true">📖</span><span class="sr-only">Docs</span></a></li>
15 </ol>
16</nav>
17
18<!-- Visually hidden but available to screen readers -->
19.sr-only {
20 position: absolute;
21 width: 1px;
22 height: 1px;
23 padding: 0;
24 margin: -1px;
25 overflow: hidden;
26 clip: rect(0, 0, 0, 0);
27 white-space: nowrap;
28 border-width: 0;
29}
Common Mistakes

These are the most frequent accessibility failures found in web applications. Avoiding these mistakes will make your application usable by significantly more people. Each mistake represents a barrier that prevents assistive technology users from completing tasks.

MistakeImpactFix
Using div/span for interactive elementsNot focusable, no keyboard support, no role announcedUse native button, a, input elements
Missing alt text on informative imagesScreen readers announce file name or URLAdd descriptive alt text; alt="" for decorative
Missing labels on form inputsScreen readers announce field type onlyUse <label for="id"> or aria-label
Not handling keyboard eventsMouse-only functionality excludes keyboard usersAdd keydown handlers for Enter, Space, Escape
Low contrast textUnreadable for low-vision and colorblind usersMeet WCAG AA 4.5:1 ratio minimum
Auto-playing audio/videoDisrupts screen reader outputNever auto-play; provide play button and controls
Missing skip linksKeyboard users must tab through entire navAdd skip-to-content link as first focusable element
Removing focus outlinesKeyboard users can't see where focus isUse :focus-visible for styled focus indicators
Empty buttons / linksNo accessible name announcedUse text, aria-label, or aria-labelledby
Negative tabindex valuesBreaks natural tab orderUse tabindex="0" or reorder DOM instead

danger

The most damaging myth in accessibility is "we don't have disabled users." Over 15% of the global population has a disability. Accessibility benefits everyone — captions help in noisy environments, keyboard shortcuts help power users, clear focus indicators help everyone on touchscreens, and semantic HTML improves SEO. Accessibility is not a cost center — it is a quality multiplier.
Best Practices
PracticeApproachPriority
Use semantic HTML first<button> not <div onclick> — native elements have built-in accessibilityCritical
Keyboard operabilityEvery interactive element must work with Tab, Enter, Space, Arrow, EscapeCritical
Focus managementFocus visible, trapped in modals, returned on close, skip links presentCritical
ARIA when neededOnly when HTML cannot express the semantics — follow the first rule of ARIAHigh
Error associationLink errors to fields via aria-describedby, announce via role="alert"High
Color contrastWCAG AA minimum: 4.5:1 for text, 3:1 for large text and UI componentsHigh
Live regionsUse aria-live for dynamic updates — polite for non-urgent, assertive for criticalHigh
Screen reader testingTest with VoiceOver (Mac) and NVDA (Windows) — automated tools catch only 30-40%High
Progressive enhancementCore functionality works without JavaScript, enhanced with itMedium
Testing with real usersAutomated tools cannot replace manual testing with assistive technologyMedium

Essential Accessibility Guidelines

Every image must have an alt attribute — empty for decorative, descriptive for informative
All form inputs must have associated labels — use <label for>, aria-label, or aria-labelledby
Interactive elements must be reachable and operable via keyboard alone
Focus indicators must be visible — never remove outlines without a replacement
Dynamic content updates must be announced to screen readers via live regions
Modal dialogs must trap focus and return it to the trigger on close
Error messages must be programmatically associated with their fields
Test with actual screen readers — VoiceOver, NVDA, TalkBack
Use heading hierarchy correctly — h1 for page title, h2 for sections, h3 for subsections
Provide a skip link as the first focusable element on every page
$Blueprint — Engineering Documentation·Section ID: JS-ACCESSIBLE·Revision: 1.0