JavaScript — Accessible Interactive Components
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.
| 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
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.
| 1 | // Focus trapping in a modal dialog |
| 2 | function 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 |
| 42 | function 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 |
| 58 | function 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 | } |
| 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> |
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
| Attribute | Description | Example |
|---|---|---|
| aria-label | Provides an accessible name when visible text is insufficient | aria-label="Close dialog" |
| aria-labelledby | References another element's text as the accessible name | aria-labelledby="title-element" |
| aria-describedby | References additional descriptive text (announced after the label) | aria-describedby="error-msg" |
Common ARIA Roles
| Role | Element | Required Children |
|---|---|---|
| dialog | Modal or inline dialog | — |
| alertdialog | Dialog requiring user response | — |
| tablist | Container for tab elements | [role="tab"] |
| tab | Selectable tab button | — |
| tabpanel | Content panel associated with tab | — |
| menu | Menu of choices or actions | [role="menuitem"] |
| menuitem | Selectable option in a menu | — |
| listbox | List of selectable options | [role="option"] |
| option | Selectable item in a listbox | — |
| tree | Hierarchical list with expandable items | [role="treeitem"] |
| toolbar | Collection of related controls | — |
| alert | Important, time-sensitive message | — |
| status | Advisory, non-urgent message | — |
| progressbar | Indicator of progress completion | — |
| tab | Selectable tab button | — |
State & Property Attributes
| Attribute | Type | Description |
|---|---|---|
| aria-expanded | State | Whether a collapsible section is open (true/false) |
| aria-hidden | State | Whether an element is hidden from accessibility tree |
| aria-selected | State | Whether an item in a list/tablist is currently selected |
| aria-checked | State | Checkbox/radio state: true, false, or mixed |
| aria-disabled | State | Element is perceivable but not operable |
| aria-invalid | State | Value does not conform to expected format |
| aria-required | Property | Input field is required before form submission |
| aria-live | Property | Region announced on dynamic updates: polite, assertive, off |
| aria-controls | Property | References the element controlled by this element |
| aria-owns | Property | Declares a parent-child relationship not in DOM |
| aria-activedescendant | Property | Identifies the currently active descendant in a composite widget |
| 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
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.
| Pattern | Usage | Urgency |
|---|---|---|
| aria-live="polite" | Search results count, form progress, timer updates | Low — waits for idle |
| aria-live="assertive" | Error messages, critical alerts, session timeout | High — interrupts current speech |
| role="status" | Status messages, loading indicators, save confirmations | Low — polite + atomic |
| role="alert" | Error notifications, validation failures, warnings | High — assertive + atomic |
| role="log" | Chat messages, activity feeds, event logs | Low — newest additions announced |
info
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).
| 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> |
| 1 | // Accessible modal using native <dialog> element |
| 2 | const dialog = document.getElementById('confirm-dialog'); |
| 3 | const openBtn = document.getElementById('open-dialog'); |
| 4 | const cancelBtn = document.getElementById('dialog-cancel'); |
| 5 | const confirmBtn = document.getElementById('dialog-confirm'); |
| 6 | |
| 7 | // Open modal — focus moves into it automatically |
| 8 | openBtn.addEventListener('click', () => { |
| 9 | dialog.showModal(); // Adds backdrop, traps focus, handles Escape |
| 10 | }); |
| 11 | |
| 12 | // Close on Cancel button or Escape key (built-in) |
| 13 | cancelBtn.addEventListener('click', () => { |
| 14 | dialog.close('cancel'); |
| 15 | }); |
| 16 | |
| 17 | // Handle confirmation |
| 18 | confirmBtn.addEventListener('click', () => { |
| 19 | dialog.close('confirm'); |
| 20 | }); |
| 21 | |
| 22 | // Handle the result after close |
| 23 | dialog.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: |
| 32 | function 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 | |
| 49 | function 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 | } |
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.
| 1 | // Accessible dropdown menu pattern |
| 2 | class 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 | } |
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).
| 1 | // Accessible Tabs component with roving tabindex |
| 2 | class 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 |
| 47 | const tabs = new AccessibleTabs(document.querySelector('[role="tablist"]')); |
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.
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.
| 1 | // Accessible form validation with error associations |
| 2 | function 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 | } |
best practice
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.
| Element | WCAG AA Ratio | WCAG AAA Ratio |
|---|---|---|
| Normal text (below 18px) | 4.5:1 | 7:1 |
| Large text (18px+ or 14px+ bold) | 3:1 | 4.5:1 |
| UI components and graphical objects | 3:1 | — |
| Focus indicators | 3:1 | — |
| Placeholder text | 4.5:1* | 7:1* |
| 1 | /* Focus visible — accessible focus indicator */ |
| 2 | button:focus-visible { |
| 3 | outline: 2px solid #00FF41; |
| 4 | outline-offset: 2px; |
| 5 | } |
| 6 | |
| 7 | /* Never remove focus outlines without a visible replacement */ |
| 8 | button:focus { |
| 9 | /* BAD — removes focus indicator */ |
| 10 | outline: none; |
| 11 | } |
| 12 | |
| 13 | /* GOOD — provides focus indicator only for keyboard users */ |
| 14 | button:focus:not(:focus-visible) { |
| 15 | outline: none; /* Remove for mouse clicks */ |
| 16 | } |
| 17 | button: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
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 Reader | Platform | Cost | Shortcut to Start |
|---|---|---|---|
| VoiceOver | macOS / iOS | Free (built-in) | Cmd + F5 |
| NVDA | Windows | Free (open-source) | Ctrl + Alt + N |
| JAWS | Windows | Paid ($95/year) | Insert + F11 |
| TalkBack | Android | Free (built-in) | Hold volume keys |
| Narrator | Windows | Free (built-in) | Win + Ctrl + Enter |
Screen Reader Testing Checklist
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.
| 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 | } |
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.
| Mistake | Impact | Fix |
|---|---|---|
| Using div/span for interactive elements | Not focusable, no keyboard support, no role announced | Use native button, a, input elements |
| Missing alt text on informative images | Screen readers announce file name or URL | Add descriptive alt text; alt="" for decorative |
| Missing labels on form inputs | Screen readers announce field type only | Use <label for="id"> or aria-label |
| Not handling keyboard events | Mouse-only functionality excludes keyboard users | Add keydown handlers for Enter, Space, Escape |
| Low contrast text | Unreadable for low-vision and colorblind users | Meet WCAG AA 4.5:1 ratio minimum |
| Auto-playing audio/video | Disrupts screen reader output | Never auto-play; provide play button and controls |
| Missing skip links | Keyboard users must tab through entire nav | Add skip-to-content link as first focusable element |
| Removing focus outlines | Keyboard users can't see where focus is | Use :focus-visible for styled focus indicators |
| Empty buttons / links | No accessible name announced | Use text, aria-label, or aria-labelledby |
| Negative tabindex values | Breaks natural tab order | Use tabindex="0" or reorder DOM instead |
danger
| Practice | Approach | Priority |
|---|---|---|
| Use semantic HTML first | <button> not <div onclick> — native elements have built-in accessibility | Critical |
| Keyboard operability | Every interactive element must work with Tab, Enter, Space, Arrow, Escape | Critical |
| Focus management | Focus visible, trapped in modals, returned on close, skip links present | Critical |
| ARIA when needed | Only when HTML cannot express the semantics — follow the first rule of ARIA | High |
| Error association | Link errors to fields via aria-describedby, announce via role="alert" | High |
| Color contrast | WCAG AA minimum: 4.5:1 for text, 3:1 for large text and UI components | High |
| Live regions | Use aria-live for dynamic updates — polite for non-urgent, assertive for critical | High |
| Screen reader testing | Test with VoiceOver (Mac) and NVDA (Windows) — automated tools catch only 30-40% | High |
| Progressive enhancement | Core functionality works without JavaScript, enhanced with it | Medium |
| Testing with real users | Automated tools cannot replace manual testing with assistive technology | Medium |