We've all been there: building a dropdown menu or modal dialog and getting halfway through before realizing we've forgotten focus management, keyboard navigation, and screen reader support. By the time QA flags it or a user reports it, you're stuck retrofitting accessibility into tangled component logic. The better path is clear: use accessible primitives for React & Next.js developers and let battle-tested libraries handle the hard parts while you focus on your product.
Why hand-rolling UI is a false economy
Building a dropdown from scratch looks simple. Add a button, render a menu on click, close it when you click outside. But the moment you ship it, edge cases pile up. What happens when a user presses Escape? Tab through it? Open it on a phone? Use a screen reader? Each one requires careful handling, and each mistake compounds into an accessibility debt that becomes exponentially more expensive to fix in production.
We've worked on projects where accessibility issues went unnoticed for months, only to discover that customer support was quietly absorbing complaints from users with disabilities. By then, the component was embedded in dozens of places. Refactoring became a multi-week project. The libraries we'll discuss here exist precisely because the industry learned this lesson repeatedly.
Focus traps and keyboard navigation: the invisible work
When a modal opens, focus should move inside it and never escape until it closes. When you press Escape, the modal should close and focus should return to the trigger button. When you Tab through a dropdown, the arrow keys should navigate items. These behaviors aren't luxuries—they're requirements for users relying on keyboards and screen readers.
Building this manually is deceptively complex. You need to track the previously focused element, manage a focus trap that prevents tabbing outside the modal, handle both Escape and Enter keys, and ensure ARIA roles are correct. A small mistake in any of these areas silently breaks the experience for an entire user segment.
Here's what a naive modal might look like without these features:
// ❌ Accessibility problems: no focus trap, no escape key, no ARIA
export function BadModal({ isOpen, onClose, children }) {
return (
isOpen && (
<div>
<div onClick={onClose}></div>
<div>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
)
);
}Contrast that with what a library gives you automatically. A modal from Radix or Headless UI handles focus restoration, traps keyboard navigation inside the modal, responds to Escape, announces its presence to screen readers, and prevents body scroll—all configured correctly by default.
ARIA roles, live regions, and semantic structure
ARIA (Accessible Rich Internet Applications) attributes tell screen readers what a component is and how to interact with it. A dropdown needs role="listbox", menu items need role="option", and the trigger needs aria-expanded to indicate state. A modal needs role="dialog" and aria-modal="true". A combobox is entirely different from a dropdown and requires its own set of attributes.
Getting these right matters. A screen reader user with no ARIA context can't tell what a component does or how to use it. With correct ARIA, they get immediate clarity. The challenge is that different components need different ARIA patterns, and these patterns change as specs evolve. Libraries keep pace with these updates; hand-rolled code often falls behind.
Live regions add another layer. When you load search results into a dropdown asynchronously, you should announce the number of results to screen reader users. That's a live region. A simple <div role="status" aria-live="polite"> won't work everywhere; you need to manage timing, text content, and announcements carefully.
Meet the libraries: Radix, React Aria, and Headless UI
Three libraries dominate the React accessibility landscape, each with a slightly different philosophy.
Radix UI
Radix provides unstyled, accessible components with a rich prop API. It ships with a companion library for styling, but you can style primitives however you want. Radix is particularly strong for complex components: tooltips, popovers, context menus, and dropdowns all work reliably out of the box.
import * as Select from '@radix-ui/react-select';
export function MySelect() {
return (
<Select.Root>
<Select.Trigger>
<Select.Value placeholder="Pick an option" />
</Select.Trigger>
<Select.Content>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
</Select.Content>
</Select.Root>
);
}Radix handles focus management, keyboard navigation, ARIA attributes, and positioning without you thinking about it. You apply CSS or Tailwind to the rendered elements and move on.
React Aria
React Aria is a collection of hooks from Adobe that emphasize hooks-based composition. It's more flexible than Radix but requires more wiring. If you prefer building components from hooks rather than consuming pre-built primitives, React Aria is your choice. It's particularly useful in design systems where you need fine-grained control.
import { useComboBox } from 'react-aria';
import { useComboBoxState } from 'react-stately';
export function ComboBox(props) {
let state = useComboBoxState(props);
let ref = useRef(null);
let { inputProps, listBoxProps, labelProps } = useComboBox(props, state, ref);
return (
<>
<label {...labelProps}>Instrument</label>
<input {...inputProps} ref={ref} />
<ul {...listBoxProps}>
{/* items */}
</ul>
</>
);
}React Aria excels when you need to build custom-shaped components but can't afford to lose accessibility guarantees. The hooks handle keyboard logic, state, and ARIA in isolation, letting you compose them freely.
Headless UI
Headless UI, built by Tailwind Labs, is intentionally minimal. It provides the core unstyled components with excellent Tailwind integration. It's ideal if you're already using Tailwind and want straightforward, opinion-light primitives. The library is smaller and has a gentler learning curve than Radix.
import { Listbox } from '@headlessui/react';
export function MyListbox() {
const [selected, setSelected] = useState('option1');
return (
<Listbox value={selected} onChange={setSelected}>
<Listbox.Button>{selected}</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="option1">Option 1</Listbox.Option>
<Listbox.Option value="option2">Option 2</Listbox.Option>
</Listbox.Options>
</Listbox>
);
}All three handle the accessibility heavy lifting. Your choice depends on how much customization you need and whether you prefer components or hooks.
Style them however you like: separation of concerns
A critical feature of modern accessible primitives is that they separate behavior from presentation. You get unstyled HTML with ARIA and keyboard logic baked in. You apply CSS, Tailwind classes, or CSS-in-JS on top. This means you're never locked into a default design and you can match your brand exactly.
import * as Select from '@radix-ui/react-select';
import styles from './select.module.css';
export function StyledSelect() {
return (
<Select.Root>
<Select.Trigger className={styles.trigger}>
<Select.Value />
</Select.Trigger>
<Select.Content className={styles.content}>
<Select.Item value="a" className={styles.item}>
Option A
</Select.Item>
</Select.Content>
</Select.Root>
);
}The library ensures that no matter how you style it, focus management and keyboard behavior remain intact. Styling a component doesn't require touching the accessibility layer. This is the dream: accessible defaults that you customize without sacrifice.
Accessibility bugs are expensive to fix later
Consider the cost-benefit. Using a library adds a dependency and a few KB to your bundle. Fixing accessibility bugs after shipping costs engineering time, potentially regulatory exposure, and user frustration. We've seen teams spend weeks retrofitting focus management into a modal that was built without it. We've seen production incidents where keyboard users couldn't navigate a critical flow. All of this was preventable.
There's also the compliance angle. If your product is covered by accessibility regulations—WCAG 2.1 AA in the EU, Section 508 in the US, or similar requirements elsewhere—shipping inaccessible components creates legal liability. Catching issues early through primitives that are battle-tested is far cheaper than litigation.
More importantly: it's the right thing. Users with disabilities are real users. They rely on your product. Locking them out through poor accessibility isn't a nice-to-have optimization; it's a fundamental failure of product responsibility.
Common mistakes
Forgetting that styled components still need ARIA. You can style a div to look like a button, but if it's still a div, screen readers won't recognize it as a button. Use semantic HTML or add explicit ARIA roles. Better yet, use a primitive that handles this automatically.
Mixing hand-rolled and library components. If you use Radix for dropdowns but hand-roll modals, you're only partially solving the problem. Commit to a primitive strategy across your codebase. The consistency pays dividends.
Overriding default ARIA attributes. When Radix sets aria-expanded="true", don't override it with a custom value. Trust the library. If the behavior isn't what you need, choose a different primitive or file an issue.
Testing only with a keyboard and mouse. Borrow a screen reader (NVDA on Windows is free, VoiceOver is built into macOS) and actually test. What feels accessible in Chrome DevTools often breaks when you hear it read aloud.
Key takeaways
- Use accessible primitives—Radix, React Aria, or Headless UI—instead of hand-rolling dropdowns, modals, and complex UI patterns.
- Primitives handle focus management, keyboard navigation, ARIA roles, and semantic structure so you don't have to.
- All three major libraries ship unstyled, letting you apply your own CSS or Tailwind without sacrificing accessibility guarantees.
- Accessibility bugs caught early through library defaults are orders of magnitude cheaper to fix than retrofitting them after shipping.
- Accessibility is not a feature; it's a responsibility. Using primitives is a practical way to honor it from day one.






Comments
Loading comments…