Radix UI is an open-source library of over 20 headless, unstyled UI primitives designed for building high-quality, accessible web applications with React. Each primitive ships with built-in keyboard navigation, focus management, and correct ARIA attributes out of the box, freeing teams from the error-prone task of manually implementing the WAI-ARIA Authoring Practices for common interaction patterns such as dialogs, dropdown menus, tooltips, tabs, accordions, and popovers.
Unlike traditional component libraries that bundle opinions about visual design, Radix primitives are completely unstyled. They expose a composable, slot-based API that gives developers full control over rendered markup and styling while guaranteeing that assistive-technology semantics remain correct. This architecture is the foundation upon which shadcn/ui and many other design-system projects are built, and it is the approach used in an internal component library and the merchant dashboard to deliver consistent, accessible UI across multiple products.
Understanding Radix primitives is essential for any frontend engineer who needs to ship accessible interfaces quickly. Rather than reinventing focus traps, roving tab indices, and screen-reader announcements for every new widget, you compose battle-tested primitives and layer your own design tokens on top. This document covers the core concepts, composition patterns, and real-world implementation strategies drawn from production code.
Radix components render semantic HTML elements with correct ARIA roles and states but apply zero visual styles. Styling is entirely the consumer’s responsibility, whether through CSS modules, Tailwind, CSS-in-JS, or plain class names. This means a <Dialog.Content> produces a div[role="dialog"] with focus trap logic, but no colours, padding, or border-radius. You bring the design; Radix brings the behavior.
Every Radix primitive is a family of sub-components that share implicit state through React context. For example, DropdownMenu exposes Root, Trigger, Portal, Content, Item, Separator, and more. You compose them declaratively, and the library manages open/close state, keyboard focus, and ARIA ownership automatically. This pattern keeps the API surface small while remaining highly flexible.
Radix primitives implement the full keyboard contract specified by WAI-ARIA Authoring Practices. Menus support arrow-key roving, type-ahead search, and Escape to close. Dialogs trap focus and restore it on unmount. Tabs use arrow keys for panel switching with optional automatic or manual activation modes. All of this is handled internally so consumers never need to attach onKeyDown handlers for standard interaction patterns.
Every Radix component accepts an asChild boolean prop. When set, the component does not render its default DOM element. Instead, it clones the single child element and merges its own props (event handlers, ARIA attributes, refs) onto that child. This enables full control over the rendered tag without wrapper-div pollution and is critical for integrating Radix with custom design-system components.
Radix ships each primitive as a separate npm package under the @radix-ui/react-* scope. You install only the primitives you need, keeping bundle size minimal:
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu \
@radix-ui/react-tooltip @radix-ui/react-tabs @radix-ui/react-accordion
A fully accessible modal dialog requires focus trapping, scroll locking, and proper ARIA labelling. With Radix, all of that is built in:
import * as Dialog from "@radix-ui/react-dialog";
export function ConfirmDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="btn-primary">Delete Item</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title>Confirm Deletion</Dialog.Title>
<Dialog.Description>
This action cannot be undone. Are you sure?
</Dialog.Description>
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="btn-secondary">Cancel</button>
</Dialog.Close>
<button className="btn-danger" onClick={handleDelete}>
Delete
</button>
</div>
<Dialog.Close asChild>
<button className="icon-btn" aria-label="Close">
×
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Behind the scenes, Dialog.Content renders a div[role="dialog"][aria-modal="true"], traps focus inside the content area, locks body scroll, and restores focus to the trigger when closed. Dialog.Title is automatically linked via aria-labelledby, and Dialog.Description via aria-describedby.
Radix DropdownMenu implements the full WAI-ARIA menu pattern, including arrow-key roving focus, type-ahead character search, and sub-menu support:
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronRight } from "lucide-react";
export function ActionsMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="icon-btn" aria-label="Actions">
…
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="menu-content"
sideOffset={5}
align="end"
>
<DropdownMenu.Item className="menu-item">
Edit
</DropdownMenu.Item>
<DropdownMenu.Item className="menu-item">
Duplicate
</DropdownMenu.Item>
<DropdownMenu.Separator className="menu-sep" />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger className="menu-item">
Move to…
<ChevronRight size={14} />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent className="menu-content">
<DropdownMenu.Item className="menu-item">
Workspace A
</DropdownMenu.Item>
<DropdownMenu.Item className="menu-item">
Workspace B
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
<DropdownMenu.Separator className="menu-sep" />
<DropdownMenu.Item
className="menu-item menu-item--danger"
onSelect={handleDelete}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
All Radix primitives support both uncontrolled (internal state) and controlled usage. You pass a value prop and an onValueChange callback to take control:
import * as Tabs from "@radix-ui/react-tabs";
import { useState } from "react";
export function SettingsTabs() {
const [activeTab, setActiveTab] = useState("general");
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab}>
<Tabs.List className="tabs-list" aria-label="Settings">
<Tabs.Trigger className="tab-trigger" value="general">
General
</Tabs.Trigger>
<Tabs.Trigger className="tab-trigger" value="security">
Security
</Tabs.Trigger>
<Tabs.Trigger className="tab-trigger" value="billing">
Billing
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="tab-panel" value="general">
<GeneralSettings />
</Tabs.Content>
<Tabs.Content className="tab-panel" value="security">
<SecuritySettings />
</Tabs.Content>
<Tabs.Content className="tab-panel" value="billing">
<BillingSettings />
</Tabs.Content>
</Tabs.Root>
);
}
The Tabs.List renders with role="tablist", each Tabs.Trigger with role="tab" and correct aria-selected state, and each Tabs.Content with role="tabpanel" linked via aria-labelledby. Arrow-key navigation between tabs is handled automatically.
An internal component library provides a shared, accessible set of components that wraps Radix primitives with design tokens and consistent styling across all search platform products. Key uses include:
Dialog wrapping a cmdk-style command interface with type-ahead search, keyboard-first navigation, and grouped results. Focus is trapped within the dialog, and pressing Escape dismisses it, returning focus to the previous element.Popover primitives power filter panels that anchor to trigger buttons, auto-flip when near viewport edges, and dismiss on outside click or Escape. ARIA attributes ensure screen readers announce the expanded state.Toast primitive manages a viewport region with live-region announcements, swipe- to-dismiss gestures, and automatic timeout, ensuring that transient messages are accessible to assistive technologies.Tooltip to provide accessible labels. The tooltip respects hover delay, persists while the pointer is within the content, and dismisses on Escape without closing parent menus.The merchant dashboard uses Radix primitives extensively in its multi-tenant administration dashboard:
NavigationMenu provides a top-level nav bar with animated sub-menus, viewport-aware positioning, and full arrow-key traversal. Each menu item correctly announces its expanded/collapsed state.DropdownMenu for row-level actions (edit, archive, delete). The menu opens on click or Enter/Space, supports arrow-key navigation, and focuses back on the trigger row when dismissed.Accordion with single or multiple expand modes. Each section header is a button with aria-expanded, and the content region animates open using CSS transitions on the data-state attribute.Dialog is composed as a side-sheet for editing records inline. The overlay prevents interaction with the page behind, scroll is locked, and focus is trapped within the sheet until the user closes it.The most common pattern in production is to wrap each Radix primitive in a typed, styled component that enforces design-system conventions while preserving accessibility guarantees:
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
// Overlay
const DialogOverlay = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
// Content
const DialogContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"w-full max-w-lg rounded-lg bg-card p-6 shadow-xl",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = "DialogContent";
export {
DialogPrimitive.Root as Dialog,
DialogPrimitive.Trigger as DialogTrigger,
DialogContent,
DialogPrimitive.Title as DialogTitle,
DialogPrimitive.Description as DialogDescription,
DialogPrimitive.Close as DialogClose,
};
data-stateRadix primitives expose a data-state attribute (e.g., "open" or "closed") that you can target in CSS for enter/exit animations without any JavaScript animation library:
/* Accordion content animation using CSS grid trick */
.accordion-content {
overflow: hidden;
transition: grid-template-rows 300ms ease;
display: grid;
grid-template-rows: 0fr;
}
.accordion-content[data-state="open"] {
grid-template-rows: 1fr;
}
.accordion-content > div {
min-height: 0;
}
/* Dropdown menu fade + slide */
.menu-content[data-state="open"] {
animation: menuIn 150ms ease;
}
.menu-content[data-state="closed"] {
animation: menuOut 100ms ease;
}
@keyframes menuIn {
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes menuOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.96) translateY(-4px); }
}
Real-world features frequently compose several Radix primitives together. For example, a color-picker popover that contains a tooltip on each swatch:
import * as Popover from "@radix-ui/react-popover";
import * as Tooltip from "@radix-ui/react-tooltip";
interface ColorPickerProps {
value: string;
onChange: (color: string) => void;
colors: { name: string; hex: string }[];
}
export function ColorPicker({ value, onChange, colors }: ColorPickerProps) {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className="color-trigger"
style={{ backgroundColor: value }}
aria-label={`Current color: ${value}`}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="color-grid" sideOffset={8}>
<Tooltip.Provider delayDuration={200}>
{colors.map((c) => (
<Tooltip.Root key={c.hex}>
<Tooltip.Trigger asChild>
<button
className="swatch"
style={{ backgroundColor: c.hex }}
aria-label={c.name}
aria-pressed={value === c.hex}
onClick={() => onChange(c.hex)}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="tooltip" sideOffset={4}>
{c.name}
<Tooltip.Arrow className="tooltip-arrow" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
))}
</Tooltip.Provider>
<Popover.Arrow className="popover-arrow" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
Radix Select replaces native <select> with full keyboard support and custom item rendering while maintaining correct ARIA listbox semantics:
import * as Select from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
interface Option {
value: string;
label: string;
icon: React.ReactNode;
}
export function StatusSelect({ options }: { options: Option[] }) {
return (
<Select.Root defaultValue={options[0].value}>
<Select.Trigger className="select-trigger" aria-label="Status">
<Select.Value placeholder="Select status" />
<Select.Icon>
<ChevronDown size={14} />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="select-content" position="popper">
<Select.Viewport className="select-viewport">
{options.map((opt) => (
<Select.Item
key={opt.value}
value={opt.value}
className="select-item"
>
<Select.ItemIndicator className="select-indicator">
<Check size={14} />
</Select.ItemIndicator>
<Select.ItemText>
<span className="select-item-row">
{opt.icon}
{opt.label}
</span>
</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
Dialog.Title: Every Radix Dialog must include a Title sub-component (or use aria-label on Content). Omitting it produces a console warning and means screen readers cannot announce the dialog’s purpose. If you want a visually hidden title, wrap it in a VisuallyHidden component.asChild around multiple children: The asChild prop expects exactly one React element child. If you pass a fragment or multiple children, Radix will throw a runtime error. Always ensure there is a single direct child element when using asChild.Portal for overlays: Components like DropdownMenu.Content and Dialog.Content should be rendered inside a Portal to avoid overflow: hidden or z-index stacking issues from ancestor containers. Skipping the portal can cause menus to be clipped or hidden behind other content.data-state="closed", the element may remain visible briefly without any visual transition. Always pair open and close animations.onKeyDown to a Radix primitive and calling event.preventDefault() or event.stopPropagation() can break the built-in keyboard navigation. Only add custom key handlers for keys that Radix does not already handle, and avoid stopping propagation unless absolutely necessary.onOpenChange callback: If you pass an open prop without a corresponding onOpenChange handler, the primitive will appear stuck open or closed because Radix cannot update the state internally. Always pair controlled value props with their change callbacks.modal={false}: By default, Radix menus and popovers are modal, trapping focus and dismissing on outside click. If you need a non-modal popover inside a dialog (e.g., a date picker within a form dialog), set modal={"{false}"} on the inner primitive to avoid conflicting focus traps.sideOffset and collision handling: Radix uses Floating UI internally for positioning. Not providing sideOffset can cause popovers to overlap their triggers. Relying on fixed positioning rather than the built-in collision-aware placement will cause content to overflow the viewport on small screens.