// LOADING
// LOADING
// LOADING_ARTICLE
Accessibility (a11y) is often treated as an afterthought — something to bolt on before a compliance review. That's the wrong frame. An accessible component is a well-engineered component: it has clear semantics, predictable keyboard behavior, and communicates state to all users, including those using assistive technology. Most of these properties also make components easier to test.
The single highest-leverage a11y decision you make is choosing the right HTML element. Native elements come with built-in semantics, keyboard behavior, and ARIA roles that you'd have to manually replicate with a <div>.
tsx// Bad: a div masquerading as a button <div onClick={handleClick} className="btn">Submit</div> // Good: a button with built-in keyboard support and ARIA role <button type="button" onClick={handleClick}>Submit</button>
The <div> version requires you to add tabIndex={0}, role="button", onKeyDown handling for Enter and Space, and you'll still miss edge cases. The <button> is accessible out of the box.
Similarly: use <nav>, <main>, <aside>, <header>, <footer>, <article>, and <section> as landmarks. Screen readers use these to let users navigate the page by region.
The first rule of ARIA is: don't use ARIA if a native HTML element can do the job. ARIA supplements HTML semantics for patterns that don't have a native equivalent — custom dropdowns, comboboxes, tab panels, modals.
Label form inputs explicitly:
tsx// Explicit label association <label htmlFor="email">Email address</label> <input id="email" type="email" /> // Or aria-label for icon-only inputs <input type="search" aria-label="Search posts" />
Describe dynamic state:
tsx<button aria-expanded={isOpen} aria-controls="menu" onClick={() => setIsOpen(o => !o)} > Menu </button> <ul id="menu" hidden={!isOpen}> {/* menu items */} </ul>
Announce live updates:
tsx// aria-live for content that updates without a page navigation <div aria-live="polite" aria-atomic="true"> {statusMessage} </div>
Use aria-live="polite" for non-urgent announcements (form save success, background loading completion). Use aria-live="assertive" only for urgent alerts like errors.
Every interactive element must be reachable and operable via keyboard alone. This means:
tabIndex={-1}).:focus-visible styles entirely.tsxfunction Dropdown({ options, onSelect }: DropdownProps) { const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const listRef = useRef<HTMLUListElement>(null); function handleKeyDown(e: React.KeyboardEvent) { switch (e.key) { case 'ArrowDown': e.preventDefault(); setActiveIndex(i => Math.min(i + 1, options.length - 1)); break; case 'ArrowUp': e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); break; case 'Enter': case ' ': e.preventDefault(); onSelect(options[activeIndex]); setOpen(false); break; case 'Escape': setOpen(false); break; } } return ( <div> <button aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(o => !o)} > Select option </button> {open && ( <ul ref={listRef} role="listbox" onKeyDown={handleKeyDown} tabIndex={-1} > {options.map((opt, i) => ( <li key={opt.value} role="option" aria-selected={i === activeIndex} onClick={() => { onSelect(opt); setOpen(false); }} > {opt.label} </li> ))} </ul> )} </div> ); }
Focus management becomes critical in dynamic UIs — modals, drawers, route changes, and inline edit modes.
When a modal opens, focus must move into it. When it closes, focus must return to the trigger element. Users should not be able to Tab out of an open modal into background content.
tsximport { useEffect, useRef } from 'react'; function Modal({ isOpen, onClose, children }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); // pass this from parent useEffect(() => { if (isOpen) { // Move focus into the modal modalRef.current?.focus(); } }, [isOpen]); if (!isOpen) return null; return ( <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} // makes the div programmatically focusable onKeyDown={e => e.key === 'Escape' && onClose()} > <h2 id="modal-title">Dialog Title</h2> {children} <button onClick={onClose}>Close</button> </div> ); }
For production use, reach for @radix-ui/react-dialog or focus-trap-react rather than hand-rolling focus trap logic. Radix UI primitives are unstyled, accessible by default, and cover edge cases thoroughly.
WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text and UI components. Use browser DevTools or extensions like axe to catch contrast failures during development.
Don't rely on color alone to communicate state — add an icon, text, or pattern alongside color for error/success states.
@axe-core/react or the jest-axe package catches around 30–40% of common a11y issues in unit tests.Accessibility testing complements other quality practices. If you're looking at component patterns more broadly, React Best Practices covers component design holistically, and good form accessibility pairs naturally with the form patterns in Forms in React: Controlled, Uncontrolled, and the Right Library.
Accessible React components don't require a separate workflow. Semantic HTML as the foundation, correct ARIA for dynamic patterns, visible focus styles, and keyboard-complete interactions cover the overwhelming majority of users' needs. Build these habits into your component authoring from the start, and a11y becomes a property of your codebase rather than a remediation task. If you'd like to discuss accessibility patterns or review specific components, feel free to reach out.