Skip to content
Component Library

Radix UI Primitives — Component Library Guide

Unstyled, accessible component primitives for building high-quality design systems and web apps. WAI-ARIA compliant with full keyboard navigation.

★ Editor's Pick TypeScript MIT
9.8M weekly downloads
16.5k GitHub stars
tree-shakeable bundle size
v1.1.3 latest version
accessibleunstyledprimitiveswai-ariaheadless

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

  • asChild merges props - when you pass asChild, Radix merges its event handlers with the child's. Don't override onClick on 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 + onOpenChange for controlled mode; omit both for uncontrolled. Mixing the two (passing open but 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.Title and Dialog.Description present even if visually hidden, since they drive the accessible name.
  • 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

Frequently Asked Questions

Does Radix UI provide any default styles?

No. Radix UI primitives are completely unstyled. You bring your own CSS, Tailwind classes, or CSS-in-JS. This is intentional - Radix handles behaviour and accessibility, you handle appearance.

Is Radix UI accessible out of the box?

Yes. Every Radix primitive is built to the WAI-ARIA specification and tested with major screen readers including JAWS, NVDA, and VoiceOver. Keyboard navigation, focus management, and ARIA attributes are all handled for you.

Do I install all Radix components at once?

No. Each component is a separate package: @radix-ui/react-dialog, @radix-ui/react-select, and so on. Install only what you use, which keeps your bundle tree-shakeable.

What is the difference between Radix UI and Headless UI?

Both are unstyled and accessible. Radix covers more component types and gives you finer composability control with sub-components like Dialog.Root, Dialog.Trigger, Dialog.Content. Headless UI is simpler and more Tailwind-focused. Either is a solid choice.

Can I use Radix UI with React Server Components?

Radix components are interactive and require client-side JavaScript. Wrap them in a 'use client' boundary when using Next.js App Router. Non-interactive wrappers around them can remain server components.