TL;DR: Radix UI Primitives are unstyled, accessible React components that handle focus trapping, keyboard nav, and ARIA attributes for you. Build your design system on top without worrying about accessibility basics. Powers shadcn/ui under the hood. Official docs: radix-ui.com.
What is Radix UI Primitives?
Radix UI is a collection of low-level, unstyled React components that handle the hard parts of UI accessibility: focus management, keyboard navigation, ARIA attributes, and screen reader announcements. You bring the styles - Radix handles correctness.
It's the foundation that powers shadcn/ui, Clerk, and thousands of design systems. Each primitive is a separate npm package, so you only ship what you actually use. Think of it as the engine, not the paint job. Radix decides what happens when you press Escape inside a dialog, where focus lands after a popover closes, and how a screen reader announces an open menu. Those decisions are the parts most teams get wrong when they hand-roll components, and they're exactly what Radix gets right.
The reason this matters is subtle. A dropdown that looks perfect can still trap keyboard users, break on mobile, or read as gibberish in VoiceOver. Getting accessibility correct isn't a styling problem, it's a behavior problem, and behavior is hard to test by eye. Radix encodes years of accessibility research into components you compose, so the correctness ships for free and your design choices stay entirely yours.
Who should reach for Radix directly?
Use Radix directly when you're building a custom design system and need accessible primitives to style yourself, when shadcn/ui's opinions don't fit your design language, or when you need a component type not yet in shadcn/ui such as @radix-ui/react-toolbar. If you maintain a shared component library across several products, Radix gives you one accessible foundation that every team styles to match its own brand.
Use shadcn/ui instead if you want pre-styled components with sensible defaults out of the box. Since shadcn/ui copies Radix-based components into your repo, you can always start there and drop down to raw Radix later when you outgrow the defaults. Many teams do exactly that. They scaffold fast with shadcn/ui, then peel back to bare primitives for the two or three components that need bespoke behavior.
One more case worth calling out: teams migrating off an older component library. Swapping a single Select for Radix at a time, behind the same styled wrapper, lets you modernize accessibility piece by piece without a big-bang rewrite. The compound API makes that incremental swap clean.
Key Features
- Per-package installs -
@radix-ui/react-dialog,@radix-ui/react-select, etc. - Full WAI-ARIA compliance - tested with JAWS, NVDA, VoiceOver
- Controlled and uncontrolled modes - works with or without external state
- Compound component API - e.g.
<Dialog.Root>,<Dialog.Trigger>,<Dialog.Content> - Portal rendering - dialogs and popovers render in a portal by default, avoiding z-index issues
Installation
npm install @radix-ui/react-dialog @radix-ui/react-select @radix-ui/react-popover
Each component is installed separately.
Basic Usage
import * as Dialog from '@radix-ui/react-dialog'
export function Modal() {
return (
<Dialog.Root>
<Dialog.Trigger>Open modal</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<Dialog.Title>Modal title</Dialog.Title>
<Dialog.Description>Modal description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
TypeScript Tips
Radix exports prop types for every sub-component. Use them to build typed wrappers:
import * as Select from '@radix-ui/react-select'
// Build a styled Select that accepts Radix's full API
type StyledSelectProps = Select.SelectProps & {
placeholder?: string
options: Array<{ value: string; label: string }>
}
export function StyledSelect({ placeholder, options, ...props }: StyledSelectProps) {
return (
<Select.Root {...props}>
<Select.Trigger>
<Select.Value placeholder={placeholder} />
</Select.Trigger>
<Select.Portal>
<Select.Content>
{options.map(opt => (
<Select.Item key={opt.value} value={opt.value}>
{opt.label}
</Select.Item>
))}
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
Pitfalls worth knowing before you ship
asChildmerges props - when you passasChild, Radix merges its event handlers with the child's. Don't overrideonClickon the child or you'll break things. The child also has to forward refs and spread the props it receives, so a plain function component without ref forwarding will silently fail.- Portal and z-index - content renders outside the DOM tree, so CSS variables on parent elements won't be inherited unless you also set them globally. If your dialog suddenly loses its theme colors, this is usually why.
- Controlled state - use
open+onOpenChangefor controlled mode; omit both for uncontrolled. Mixing the two (passingopenbut no handler) leaves the component stuck and is one of the most common support questions. - Hydration and SSR - because the primitives are interactive, they need a client boundary. In Next.js App Router, forgetting
'use client'on a file that uses a Radix hook throws at build time rather than at runtime, which is annoying but at least loud.
Performance and accessibility notes
Radix is built so the heavy work happens only when a component opens. A closed dialog or popover doesn't mount its content, so the cost of having dozens of them on a page stays low. Because each primitive ships as its own package, your bundle only grows by the components you actually import. There's no monolithic runtime tax.
On accessibility, the headline is that focus management is handled for you. Open a dialog and focus moves inside it, Tab cycles within it, and closing it returns focus to the trigger. That round trip is the single most overlooked detail in custom modals, and Radix does it without you writing a line. In our experience, switching a hand-built modal to Radix usually removes more code than it adds.
Common Gotchas
- Test with a real screen reader at least once. Radix gets the markup right, but your labels still need to make sense.
- Keep
Dialog.TitleandDialog.Descriptionpresent even if visually hidden, since they drive the accessible name.
Related
- shadcn/ui - a component collection built on top of Radix UI primitives with pre-applied Tailwind styles
- Headless UI - similar headless/accessible alternative built by the Tailwind CSS team
- CSS Container Queries Guide - style Radix primitives with container queries for context-aware responsive components