Radix UI Accessible Primitives

Frontend Engineering intermediate Building with AI ~18 min read

Overview

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.

Core Concepts

Headless / Unstyled Primitives

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.

Compound Component Pattern

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.

Built-in Keyboard Navigation & Focus Management

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.

The asChild Prop & Slot Merging

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.

How It Works

Installing Primitives

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

Basic Dialog Example

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">
              &times;
            </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">
          &hellip;
        </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&hellip;
              <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>
  );
}

Controlled State with Callbacks

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.

Practical Applications

Shared Component Library

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:

Merchant Dashboard: Multi-Tenant Dashboard

The merchant dashboard uses Radix primitives extensively in its multi-tenant administration dashboard:

Implementation Patterns

Pattern 1: Wrapping Radix with a Design-System API

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,
};

Pattern 2: Animated Content with data-state

Radix 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); }
}

Pattern 3: Composing Multiple Primitives

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>
  );
}

Pattern 4: Accessible Select with Custom Rendering

Radix Select replaces native &lt;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>
  );
}

Common Pitfalls

Further Reading