The Compliance Trap
Most teams approach accessibility by running an automated scanner before launch and fixing the errors it flags. This catches maybe 30% of real accessibility issues. Automated tools can detect missing alt text and color contrast failures, but they cannot tell you that your custom dropdown is impossible to navigate with a keyboard or that your loading states leave screen reader users in silence.
I have worked on three production applications where the team thought they were accessible because the Lighthouse score showed green. In every case, the first session with an actual screen reader user revealed dozens of issues the scanner never flagged. The gap between automated compliance and genuine usability is enormous.
Accessibility as Architecture
The most effective approach I have found is making accessibility a first-class architectural concern rather than a last-mile fix. Every component in our design system is built with ARIA attributes, keyboard navigation, and focus management from the start. When accessibility is baked into the primitives, the applications built on top of them inherit it automatically.
This means accessibility requirements are part of the component API design process, not a separate ticket filed after the component ships. When we build a new dropdown, the keyboard navigation behavior is defined alongside the visual design in the spec document.
import { useRef, useEffect, useCallback, useState } from "react";
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
initialFocusRef?: React.RefObject<HTMLElement>;
}
function Dialog({ isOpen, onClose, title, children, initialFocusRef }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that had focus before the dialog opened
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the initial focus target or the dialog itself
const focusTarget = initialFocusRef?.current ?? dialogRef.current;
focusTarget?.focus();
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
// Restore focus to the element that triggered the dialog
previousFocusRef.current?.focus();
};
}, [isOpen, initialFocusRef]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
return;
}
// Trap focus within the dialog
if (event.key === "Tab") {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
},
[onClose]
);
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 bg-black/50" aria-hidden="true" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
ref={dialogRef}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="fixed inset-x-4 top-1/4 mx-auto max-w-lg rounded-lg bg-white p-6 shadow-xl"
>
<h2 id="dialog-title" className="text-lg font-semibold">
{title}
</h2>
{children}
</div>
</>
);
}
Three details in this component are easy to get wrong. First, storing and restoring the previously focused element when the dialog closes. Without this, keyboard users lose their place in the page after dismissing a dialog. Second, trapping focus within the dialog so Tab does not escape into the background content. Third, the aria-modal="true" attribute, which tells assistive technology that the content behind the dialog is inert.
Keyboard Navigation Patterns
Every interactive element must be operable with a keyboard alone. This goes beyond making things focusable. It means implementing the expected keyboard patterns for each widget type. A dropdown menu should open with Enter or Space, navigate with arrow keys, close with Escape, and support type-ahead search.
function useRovingTabIndex(items: string[]) {
const [activeIndex, setActiveIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLElement>>(new Map());
const setRef = useCallback((index: number, element: HTMLElement | null) => {
if (element) {
itemRefs.current.set(index, element);
} else {
itemRefs.current.delete(index);
}
}, []);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
let nextIndex = activeIndex;
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
event.preventDefault();
nextIndex = (activeIndex + 1) % items.length;
break;
case "ArrowUp":
case "ArrowLeft":
event.preventDefault();
nextIndex = (activeIndex - 1 + items.length) % items.length;
break;
case "Home":
event.preventDefault();
nextIndex = 0;
break;
case "End":
event.preventDefault();
nextIndex = items.length - 1;
break;
default:
return;
}
setActiveIndex(nextIndex);
itemRefs.current.get(nextIndex)?.focus();
},
[activeIndex, items.length]
);
const getItemProps = useCallback(
(index: number) => ({
ref: (el: HTMLElement | null) => setRef(index, el),
tabIndex: index === activeIndex ? 0 : -1,
onKeyDown: handleKeyDown,
role: "option" as const,
"aria-selected": index === activeIndex,
}),
[activeIndex, handleKeyDown, setRef]
);
return { activeIndex, getItemProps };
}
The roving tabindex pattern is the WAI-ARIA recommended approach for composite widgets like menus, toolbars, and tab lists. Only the active item has tabIndex={0}, so the Tab key moves focus to the next component outside the widget rather than cycling through every item inside it. Arrow keys handle navigation within the widget.
This pattern is one of those things that seems like extra effort until you try navigating a toolbar with 15 buttons using only the Tab key. Without roving tabindex, reaching the content after the toolbar requires 15 Tab presses. With it, you need one.
ARIA Live Regions for Dynamic Content
One of the most overlooked accessibility gaps is in dynamic content updates. When a filter changes the results on screen, sighted users see the new content instantly. Screen reader users hear nothing unless you explicitly announce the change.
function useAnnouncer() {
const [announcement, setAnnouncement] = useState("");
const announce = useCallback((message: string, priority: "polite" | "assertive" = "polite") => {
// Clear and re-set to ensure screen readers pick up repeated messages
setAnnouncement("");
requestAnimationFrame(() => {
setAnnouncement(message);
});
}, []);
const AnnouncerRegion = useCallback(
() => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
),
[announcement]
);
return { announce, AnnouncerRegion };
}
// Usage in a search results component
function SearchResults({ query, results }: SearchResultsProps) {
const { announce, AnnouncerRegion } = useAnnouncer();
useEffect(() => {
if (query) {
announce(`${results.length} results found for "${query}"`);
}
}, [query, results.length, announce]);
return (
<div>
<AnnouncerRegion />
<ul role="list" aria-label={`Search results for ${query}`}>
{results.map((result) => (
<li key={result.id} role="listitem">
{result.title}
</li>
))}
</ul>
</div>
);
}
The sr-only CSS class (screen reader only) visually hides the element while keeping it accessible to assistive technology. The requestAnimationFrame trick is necessary because screen readers sometimes ignore updates to live regions if the value is set in the same synchronous execution context as the clear. The brief delay ensures the change is detected.
Color Contrast and Visual Accessibility
Color contrast is the most commonly flagged accessibility issue, and also the most commonly "fixed" in a way that misses the point. Meeting the 4.5:1 ratio for normal text and 3:1 for large text is the minimum, not the goal. We built a utility that validates contrast ratios at the design token level so issues are caught before they reach components.
function getRelativeLuminance(hex: string): number {
const rgb = hexToRgb(hex);
const [r, g, b] = rgb.map((channel) => {
const sRGB = channel / 255;
return sRGB <= 0.03928
? sRGB / 12.92
: Math.pow((sRGB + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function getContrastRatio(hex1: string, hex2: string): number {
const l1 = getRelativeLuminance(hex1);
const l2 = getRelativeLuminance(hex2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Validate all color token pairs used in the design system
function auditDesignTokenContrast(
tokens: Record<string, string>
): Array<{ pair: string; ratio: number; passes: boolean }> {
const textBgPairs = [
{ text: "text-primary", bg: "bg-default", minRatio: 4.5 },
{ text: "text-secondary", bg: "bg-default", minRatio: 4.5 },
{ text: "text-primary", bg: "bg-muted", minRatio: 4.5 },
{ text: "text-on-primary", bg: "bg-primary", minRatio: 4.5 },
{ text: "text-error", bg: "bg-default", minRatio: 4.5 },
{ text: "text-link", bg: "bg-default", minRatio: 4.5 },
];
return textBgPairs.map(({ text, bg, minRatio }) => {
const ratio = getContrastRatio(tokens[text], tokens[bg]);
return {
pair: `${text} on ${bg}`,
ratio: Math.round(ratio * 100) / 100,
passes: ratio >= minRatio,
};
});
}
We run this audit in CI whenever design tokens change. It has caught contrast regressions three times, each during a brand color update where the new color looked fine on the designer's calibrated display but failed contrast on a standard monitor.
Testing With Real Users
Automated testing plus manual keyboard testing still misses the lived experience of people with disabilities. We run quarterly usability sessions with screen reader users, and the insights are humbling. A button that our team thought was perfectly accessible turned out to announce as a meaningless label because the icon-only design had a generic aria-label.
Our testing process covers three layers. First, automated scanning with axe-core in our integration tests, which catches about 30% of issues. Second, manual keyboard and screen reader testing by developers during code review, which catches another 40%. Third, quarterly sessions with assistive technology users, which catch the remaining 30% that only real usage reveals.
The most impactful finding from user testing was about timing. Our toast notifications disappeared after 3 seconds, which was enough time for sighted users to read them but not enough for screen reader users, who might need 8-10 seconds to navigate to and read the notification. We increased the default duration to 8 seconds and added a persist option for critical messages.
Beyond Visual Disabilities
Accessibility extends far beyond screen readers. Motor impairments affect hover interactions and small click targets. Cognitive disabilities impact information density and navigation complexity. Vestibular disorders make animations uncomfortable. Building for the full spectrum of human ability makes your application better for everyone.
We enforce minimum touch targets of 44x44 pixels for all interactive elements, following WCAG 2.2 guidance. Every animation respects the prefers-reduced-motion media query. Complex multi-step flows include a progress indicator and the ability to save and resume.
Common Pitfalls
Using div and span for interactive elements. A div with an onClick handler is not a button. It lacks keyboard focus, keyboard activation, and the implicit ARIA role. Use the native button element unless you have a compelling reason not to, and even then, add role="button", tabIndex={0}, and keyboard event handlers.
aria-label on non-interactive elements. Adding aria-label to a div that has no interactive role does nothing in most screen readers. Labels apply to interactive elements and landmarks. For non-interactive descriptive text, use visible text or aria-describedby on a related interactive element.
Focus indicators removed for aesthetics. Removing the focus outline because it "looks ugly" makes your application unusable for keyboard users. Customize the focus style to match your brand, but never remove it. We use a 2px offset ring in the brand color, which is both visually clean and clearly visible.
Assuming assistive technology is only for blind users. Screen magnifiers, voice control software, switch devices, and reading aids all interact with your application differently. Testing with only VoiceOver or NVDA covers one segment of assistive technology users. We test with at least two screen readers (VoiceOver and NVDA), keyboard-only navigation, and browser zoom up to 200%.
Accessibility as a separate sprint. If accessibility work is deferred to a dedicated sprint, it never gets prioritized over feature work. Building it into every component from the start costs roughly 15% more development time per component, but retroactively adding accessibility to a finished application costs 3-5x more. We track accessibility coverage as a metric alongside test coverage, and it is part of every PR review checklist.
Accessibility is not charity work. It is engineering discipline. The techniques that make an application usable for people with disabilities also make it more robust, more testable, and more resilient to the unpredictable ways people actually use software. Every curb cut, every keyboard shortcut, every properly labeled form field makes the product better for everyone who uses it.