with Full Accessibility
Implementing WAI-ARIA correctly for your own UI components costs weeks. Building Dialog, Dropdown, Tooltip and Select with correct keyboard navigation, focus trapping and screen reader support is more complex than most developers realize. Radix UI solves exactly this problem.
Table of Contents
- 1. Why accessibility without Radix UI is so hard
- 2. The headless concept: behavior without style
- 3. Dialog: focus trapping and ARIA modal done right
- 4. DropdownMenu: keyboard navigation out of the box
- 5. Tooltip and Popover: hover, focus and touch
- 6. Select: why you should not replace native select
- 7. Styling Radix UI with Tailwind CSS: the data-state pattern
- 8. Composing: building your own component library
- 9. Radix UI vs. Headless UI vs. Ariakit
- 10. Summary
- 11. FAQ
1. Why accessibility without Radix UI is so hard
An accessible dialog is not a simple modal with position: fixed. Correct accessibility requires: focus must jump into the dialog on open, tab navigation must stay trapped inside the dialog (focus trapping), focus must return to the triggering element on close, Escape must close the dialog, the background document must be hidden from screen readers (aria-hidden), and the role dialog with aria-modal="true" and aria-labelledby must be set correctly. All of this is defined by the WAI-ARIA authoring patterns, and implementing all of it takes days, not hours.
Radix UI implements all of that in its primitives. The library consists of individually installable packages per component (@radix-ui/react-dialog, @radix-ui/react-dropdown-menu, etc.) that bring full WAI-ARIA compliance out of the box. No styling, no opinion about colors or spacing, just behavior, accessibility and state management. That is the core of the headless approach: separating behavior from appearance. Anyone who uses Radix UI gets the hard accessibility implementation for free and can invest that time in design and business logic instead.
The popularity of Radix UI reflects this advantage: shadcn/ui, the most popular React component collection in 2026, is built entirely on Radix UI primitives. Tremor, Radix Themes and numerous other component libraries use the same primitives as their foundation. This means that the investment in understanding Radix UI pays off even when you switch to already-styled component libraries or build your own systems.
2. The headless concept: behavior without style
Headless components deliver state, behavior and accessibility attributes, but no CSS. That makes them fundamentally different from classic UI libraries like Material UI or Ant Design, which ship components with a fixed design system. The advantage: Radix UI components look the way you design them, not like a generic library. This is especially important for projects with strong design requirements or their own design system.
Technically, Radix UI works through the compound component pattern: a component like Dialog.Root provides state via React Context, and all child components (Dialog.Trigger, Dialog.Content, Dialog.Title) are wired into that context. You do not have to connect anything manually, the primitives know how they belong together. The accessibility attributes are set automatically: aria-expanded, aria-controls, aria-labelledby, role, tabIndex and every other ARIA attribute is derived automatically from the component's state.
// Basic Radix UI Dialog, full accessibility with zero custom ARIA code
import * as Dialog from '@radix-ui/react-dialog'
export function ConfirmDialog({ onConfirm, children }) {
return (
<Dialog.Root>
{/* Trigger receives aria-haspopup, aria-expanded automatically */}
<Dialog.Trigger asChild>
<button className="rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700">
Delete Item
</button>
</Dialog.Trigger>
{/* Portal renders outside the current DOM tree to avoid z-index issues */}
<Dialog.Portal>
{/* Overlay covers background, aria-hidden on body automatically set */}
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
{/* Content has role="dialog", aria-modal="true", focus trapped inside */}
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-2xl bg-white p-6 shadow-xl focus:outline-none">
{/* Title is auto-connected via aria-labelledby */}
<Dialog.Title className="text-lg font-bold text-gray-900">
Confirm Delete
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-600">
This action cannot be undone.
</Dialog.Description>
<div className="mt-6 flex gap-3 justify-end">
{/* Close dismisses dialog and returns focus to trigger */}
<Dialog.Close asChild>
<button className="rounded-lg border px-4 py-2 text-sm">Cancel</button>
</Dialog.Close>
<button onClick={onConfirm} className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white">
Delete
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
3. Dialog: focus trapping and ARIA modal done right
The Radix UI Dialog primitive resolves all the complex accessibility requirements automatically: on open, focus jumps into the dialog content, more precisely to the first focusable element or to the content element itself. Tab navigation stays inside the dialog (focus trapping), no tab stop leaves the dialog while it is open. On close, whether via the Escape key, a click on the close button, or a click on the overlay, focus returns to the original trigger element.
The asChild prop is a powerful pattern in Radix UI: instead of rendering its own DOM element, the primitive passes its props and event handlers to the direct child element. Dialog.Trigger asChild combined with a <button> does not produce a nested button-in-button, it hands the trigger functionality directly to the button. This allows full control over the DOM element without any accessibility compromise. The asChild pattern works with every Radix UI primitive and is the recommended way to use your own styled components as a trigger.
4. DropdownMenu: keyboard navigation out of the box
An accessible dropdown menu requires, per WAI-ARIA: opening with Enter or Space on the trigger, navigating items with arrow keys (ArrowUp, ArrowDown), activating with Enter or Space, closing with Escape, and type-ahead search (pressing a letter jumps to the menu item that starts with that letter). That is eight different keyboard interactions, all of which Radix UI's DropdownMenu primitive implements fully.
The predefined item types are especially helpful: DropdownMenu.Item for regular items, DropdownMenu.CheckboxItem for toggle items with aria-checked, DropdownMenu.RadioGroup and DropdownMenu.RadioItem for single-choice selection with role="menuitemradio". DropdownMenu.Separator renders a visual divider with the correct role="separator". Submenus are realized via DropdownMenu.Sub, DropdownMenu.SubTrigger and DropdownMenu.SubContent, with automatic keyboard navigation through ArrowRight and ArrowLeft. All of it without a single line of custom accessibility code.
// DropdownMenu with keyboard navigation, checkboxes and separators
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useState } from 'react'
export function UserMenu({ user }) {
const [showNotifications, setShowNotifications] = useState(true)
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center gap-2 rounded-full p-1 hover:bg-gray-100">
<img src={user.avatar} alt="" className="h-8 w-8 rounded-full" />
<span className="text-sm font-medium">{user.name}</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 min-w-48 rounded-xl border bg-white p-1 shadow-lg"
sideOffset={5}
align="end"
>
{/* Regular item, activated by Enter, Space, or click */}
<DropdownMenu.Item className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm outline-none focus:bg-sky-50 focus:text-sky-700 data-[highlighted]:bg-sky-50">
Profile Settings
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-100" />
{/* CheckboxItem has aria-checked and renders checkmark automatically */}
<DropdownMenu.CheckboxItem
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm outline-none focus:bg-sky-50 data-[highlighted]:bg-sky-50"
checked={showNotifications}
onCheckedChange={setShowNotifications}
>
<DropdownMenu.ItemIndicator>✓</DropdownMenu.ItemIndicator>
Email Notifications
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator className="my-1 h-px bg-gray-100" />
{/* Destructive item, semantic styling only, no special ARIA needed */}
<DropdownMenu.Item className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 outline-none focus:bg-red-50 data-[highlighted]:bg-red-50">
Sign Out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
5. Tooltip and Popover: hover, focus and touch
Tooltips are deceptively complex: they must appear on hover, appear on keyboard focus, remain visible on touch devices after tap, and they may never be the only way to obtain important information (WCAG 2.1 Criterion 1.4.13). Radix UI's Tooltip primitive implements all of these rules: automatic delay on hover, immediate display on focus, role="tooltip" and an aria-describedby link between trigger and content, and the Escape key closes the tooltip.
The distinction between Tooltip and Popover in Radix UI matters conceptually: a tooltip shows supplementary information and is not interactive. A popover can contain interactive elements (links, buttons, forms). Radix UI's Popover primitive accordingly sets role="dialog" instead of role="tooltip" and implements focus management: on open, focus jumps into the popover content, on close it returns to the trigger. Building a tooltip element with interactive content would be an accessibility mistake, with Radix UI this semantic distinction is clear.
6. Select: why you should not replace native select
The native HTML <select> element is fully accessible by itself, it comes with keyboard navigation, screen reader support and a native mobile system dialog. So why does Radix UI's Select primitive exist? Because native select is barely customizable: custom icons, multi-line options, option groups with dividers, search inside the dropdown, and complex option rendering are all impossible or nearly impossible with a native select.
Radix UI Select reimplements the full accessibility of the native element: role="combobox" on the trigger, role="listbox" on the content, role="option" on every item, ArrowUp/ArrowDown navigation, Home/End, letter-based type-ahead, and correct aria-selected on the active item. On mobile devices, Radix UI Select additionally renders a hidden native select so forms interact correctly with the system keyboard. That is the kind of detail that is easy to miss in a manual implementation.
7. Styling Radix UI with Tailwind CSS: the data-state pattern
Radix UI communicates the current state of components via data attributes on the DOM elements. data-state="open" or data-state="closed" on a dialog overlay, data-state="checked" on a checkbox item, data-highlighted on the currently focused menu item. With Tailwind CSS v3's data-[state=open]:animate-in syntax or Tailwind CSS v4's native data attribute support, you can react directly to these states and trigger CSS animations, color changes and layout transformations.
This makes integrating Radix UI with Tailwind particularly elegant: instead of manually computing state-dependent classes and injecting them into JSX, you declare directly in the className how an element should look in each state. data-[state=checked]:bg-sky-600 on a checkbox trigger turns it blue when the checkbox is checked. data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed makes disabled items visually disabled. The state comes from Radix UI, the visualization comes from Tailwind, a clean separation of concerns.
// Radix UI + Tailwind: data-state styling pattern for animated transitions
import * as Dialog from '@radix-ui/react-dialog'
// tailwind.config.ts, enable data-state variants (Tailwind v3)
// plugins: [require('tailwindcss-animate')]
// Custom variants for data attributes are in Tailwind v4 by default
export function AnimatedDialog({ trigger, title, children }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
{/* data-[state=open] and data-[state=closed] control enter/exit animations */}
<Dialog.Overlay
className="
fixed inset-0 bg-black/40
data-[state=open]:animate-in data-[state=open]:fade-in-0
data-[state=closed]:animate-out data-[state=closed]:fade-out-0
duration-200
"
/>
<Dialog.Content
className="
fixed left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2
rounded-2xl bg-white p-6 shadow-2xl
data-[state=open]:animate-in data-[state=open]:fade-in-0
data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4
data-[state=closed]:animate-out data-[state=closed]:fade-out-0
data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-bottom-4
duration-200
"
>
<Dialog.Title className="text-xl font-bold text-gray-900">{title}</Dialog.Title>
<div className="mt-4">{children}</div>
<Dialog.Close asChild>
<button
className="absolute right-4 top-4 rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close dialog"
>
✕
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
8. Composing: building your own component library
The real power use case of Radix UI is building your own, brand-consistent component library on top of the primitives. Instead of configuring Dialog, Dropdown and Select from scratch every time, you build styled wrapper components once, using Radix UI primitives and embedding your design system tokens. The result is a library that looks consistent everywhere in the application, is fully accessible, and feels like a first-party component, because it is one.
shadcn/ui demonstrates this approach in its purest form: the components are not installed as an npm package, but copied as source code into the project, then adapted as needed. Every component uses Radix UI primitives, is styled with Tailwind, and includes TypeScript prop interfaces. This allows full customization without framework lock-in. Teams that follow this approach get complete control over every component without losing the accessibility foundation of Radix UI.
9. Radix UI vs. Headless UI vs. Ariakit
There are several headless component libraries for React pursuing similar goals. Headless UI by Tailwind Labs is the most direct alternative to Radix UI: focused on Tailwind integration, a smaller component set, but a very polished developer experience. Ariakit (formerly Reakit) is the most academic solution, with the strongest focus on WAI-ARIA compliance and a more composable API. Radix UI sits between the two: an extensive component set, a mature API design and the strongest community through shadcn/ui.
| Property | Radix UI | Headless UI | Ariakit |
|---|---|---|---|
| Component scope | Very large (30+) | Medium (15+) | Large (25+) |
| shadcn/ui integration | Base library | Not integrated | Not integrated |
| WAI-ARIA compliance | Very high | High | Very high |
| API stability | Very stable | Stable | Changing |
| Tailwind friendliness | data-state pattern | Render props | Props required |
The recommendation for 2026: Radix UI is the safest choice for new projects that want to build their own component library. Its ecosystem, stability and shadcn/ui integration give a considerable head start. Headless UI is the better choice when the project already leans heavily on Tailwind and a smaller, more focused component set is enough. Ariakit is the choice for teams that value maximum API flexibility over convenience.
Mironsoft
React component library, accessibility and design system development
Want to build your own accessible component library?
We build a brand-consistent, fully accessible component library for your React project on top of Radix UI and Tailwind CSS, with design tokens, full WCAG compliance and documentation.
Design System
Building a component library with Radix UI primitives, Tailwind and design tokens
Accessibility Audit
Checking existing UI components for WCAG 2.2 compliance and retrofitting them with Radix UI
Implementation
Correctly implementing Dialog, Dropdown, Select, Tooltip and Popover with full test coverage
10. Summary
Radix UI is the most pragmatic answer to the accessibility problem in React applications. Writing correct ARIA implementations for Dialog, Dropdown, Select and Tooltip requires deep knowledge of the WAI-ARIA authoring patterns, extensive testing with screen readers and keyboard navigation, and ongoing maintenance as browsers change. Radix UI carries this responsibility so teams can invest their time in design, business logic and features instead.
The data-state styling pattern combined with Tailwind CSS makes Radix UI a complete foundation for any design system effort. The separation between behavior (Radix) and appearance (Tailwind) is clear, maintainable, and scales from small projects to large component libraries. Teams that use Radix UI as a base and build a brand-consistent layer on top get the best of both worlds: complete design freedom and complete accessibility compliance without compromise.
Radix UI Headless Components: The Essentials at a Glance
WAI-ARIA out of the box
Focus trapping, keyboard navigation, ARIA attributes and focus return automatically implemented. No custom accessibility code needed.
asChild Pattern
Radix UI primitives delegate their props to your own DOM elements. Full DOM control without accessibility compromise.
data-state Styling
State exposed as data attributes (data-state="open", data-highlighted). Tailwind classes like data-[state=open]:animate-in for declarative state styles.
shadcn/ui Ecosystem
shadcn/ui is built on Radix UI primitives. Investing in Radix pays off across the entire shadcn/ui ecosystem.