Skip to main content
Technology & EngineeringUi Components Services366 lines

Radix UI

"Radix UI: accessible primitives, Dialog, Dropdown, Popover, Tabs, Toast, composition pattern, styling with className, controlled/uncontrolled"

Quick Summary27 lines
Radix UI provides unstyled, accessible UI primitives for building high-quality design systems and web applications. Every component handles complex accessibility requirements -- keyboard navigation, focus management, screen reader announcements, ARIA attributes -- so you can focus on visual design. Components are composable: each is a set of parts (Root, Trigger, Content, etc.) that you assemble and style however you like. Radix does not impose any styling approach; use Tailwind, CSS Modules, styled-components, or anything else.

## Key Points

- Accessibility is built in, not bolted on
- Components are unstyled -- you bring your own design
- Composition over configuration -- each component is assembled from named parts
- Supports both controlled and uncontrolled usage
- Incremental adoption -- install only the primitives you need
1. **Always use `asChild`** on Trigger components when you want to render your own button element. This merges Radix behavior onto your element without adding an extra DOM node.
2. **Use `Portal`** for overlays (Dialog, Popover, Dropdown) to render content at the end of the document body, avoiding z-index and overflow issues.
3. **Use data attributes for styling** -- Radix exposes `data-[state=open]`, `data-[state=closed]`, `data-[side=top]`, etc. These are stable API and ideal for conditional styling.
4. **Wrap primitives in your own components** to create a consistent API across your app. Export your styled version from a `ui/` directory.
5. **Use the `forceMount` prop** when you need to control mount/unmount with your own animation library (e.g., Framer Motion).
6. **Provide `sideOffset` and `align`** on Content components to control positioning relative to the trigger.
7. **Handle `onEscapeKeyDown` and `onPointerDownOutside`** to customize dismiss behavior when needed.

## Quick Example

```typescript
import * as Dialog from "@radix-ui/react-dialog";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Popover from "@radix-ui/react-popover";
import * as Tabs from "@radix-ui/react-tabs";
```
skilldb get ui-components-services-skills/Radix UIFull skill: 366 lines
Paste into your CLAUDE.md or agent config

Radix UI Skill

Core Philosophy

Radix UI provides unstyled, accessible UI primitives for building high-quality design systems and web applications. Every component handles complex accessibility requirements -- keyboard navigation, focus management, screen reader announcements, ARIA attributes -- so you can focus on visual design. Components are composable: each is a set of parts (Root, Trigger, Content, etc.) that you assemble and style however you like. Radix does not impose any styling approach; use Tailwind, CSS Modules, styled-components, or anything else.

Key principles:

  • Accessibility is built in, not bolted on
  • Components are unstyled -- you bring your own design
  • Composition over configuration -- each component is assembled from named parts
  • Supports both controlled and uncontrolled usage
  • Incremental adoption -- install only the primitives you need

Setup

Installation

# Install individual primitives (recommended)
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-popover
npm install @radix-ui/react-tabs
npm install @radix-ui/react-toast
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-accordion

# Each primitive is a separate package to keep bundles small

Import Pattern

import * as Dialog from "@radix-ui/react-dialog";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Popover from "@radix-ui/react-popover";
import * as Tabs from "@radix-ui/react-tabs";

Key Techniques

Dialog (Modal)

import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";

export function ConfirmDialog({
  title,
  description,
  onConfirm,
  children,
}: {
  title: string;
  description: string;
  onConfirm: () => void;
  children: React.ReactNode;
}) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>{children}</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-fadeIn" />
        <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-xl w-full max-w-md data-[state=open]:animate-contentShow">
          <Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
          <Dialog.Description className="mt-2 text-sm text-gray-500">
            {description}
          </Dialog.Description>
          <div className="mt-6 flex justify-end gap-3">
            <Dialog.Close asChild>
              <button className="px-4 py-2 text-sm rounded-md border hover:bg-gray-50">
                Cancel
              </button>
            </Dialog.Close>
            <Dialog.Close asChild>
              <button
                onClick={onConfirm}
                className="px-4 py-2 text-sm rounded-md bg-red-600 text-white hover:bg-red-700"
              >
                Confirm
              </button>
            </Dialog.Close>
          </div>
          <Dialog.Close asChild>
            <button className="absolute top-3 right-3 p-1 rounded-full hover:bg-gray-100" aria-label="Close">
              <X className="h-4 w-4" />
            </button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Dropdown Menu

import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { useState } from "react";

export function SettingsMenu() {
  const [bookmarks, setBookmarks] = useState(true);
  const [urls, setUrls] = useState(false);
  const [person, setPerson] = useState("pedro");

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button className="rounded-md border px-3 py-2 text-sm hover:bg-gray-50">
          Options
        </button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="min-w-[220px] rounded-md bg-white p-1 shadow-lg border"
          sideOffset={5}
        >
          <DropdownMenu.Item className="flex cursor-pointer items-center rounded px-2 py-1.5 text-sm outline-none hover:bg-gray-100">
            New Tab
          </DropdownMenu.Item>
          <DropdownMenu.Item className="flex cursor-pointer items-center rounded px-2 py-1.5 text-sm outline-none hover:bg-gray-100">
            New Window
          </DropdownMenu.Item>
          <DropdownMenu.Separator className="my-1 h-px bg-gray-200" />

          <DropdownMenu.CheckboxItem
            className="flex cursor-pointer items-center rounded px-2 py-1.5 pl-6 text-sm outline-none hover:bg-gray-100 relative"
            checked={bookmarks}
            onCheckedChange={setBookmarks}
          >
            <DropdownMenu.ItemIndicator className="absolute left-1">
              <Check className="h-3.5 w-3.5" />
            </DropdownMenu.ItemIndicator>
            Show Bookmarks
          </DropdownMenu.CheckboxItem>

          <DropdownMenu.Separator className="my-1 h-px bg-gray-200" />

          <DropdownMenu.RadioGroup value={person} onValueChange={setPerson}>
            <DropdownMenu.Label className="px-2 py-1.5 text-xs text-gray-500">
              People
            </DropdownMenu.Label>
            <DropdownMenu.RadioItem
              className="flex cursor-pointer items-center rounded px-2 py-1.5 pl-6 text-sm outline-none hover:bg-gray-100 relative"
              value="pedro"
            >
              <DropdownMenu.ItemIndicator className="absolute left-1">
                <Circle className="h-2 w-2 fill-current" />
              </DropdownMenu.ItemIndicator>
              Pedro Duarte
            </DropdownMenu.RadioItem>
            <DropdownMenu.RadioItem
              className="flex cursor-pointer items-center rounded px-2 py-1.5 pl-6 text-sm outline-none hover:bg-gray-100 relative"
              value="colm"
            >
              <DropdownMenu.ItemIndicator className="absolute left-1">
                <Circle className="h-2 w-2 fill-current" />
              </DropdownMenu.ItemIndicator>
              Colm Tuite
            </DropdownMenu.RadioItem>
          </DropdownMenu.RadioGroup>

          <DropdownMenu.Arrow className="fill-white" />
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Popover

import * as Popover from "@radix-ui/react-popover";
import { Settings } from "lucide-react";

export function DimensionsPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <button className="inline-flex items-center gap-1 rounded-md border px-3 py-2 text-sm" aria-label="Dimensions">
          <Settings className="h-4 w-4" /> Dimensions
        </button>
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Content
          className="w-72 rounded-md bg-white p-4 shadow-lg border"
          sideOffset={5}
        >
          <p className="mb-3 text-sm font-medium">Dimensions</p>
          <div className="space-y-3">
            {["Width", "Max Width", "Height", "Max Height"].map((label) => (
              <fieldset key={label} className="flex items-center gap-3">
                <label className="w-20 text-sm text-gray-600">{label}</label>
                <input
                  className="flex-1 rounded border px-2 py-1 text-sm"
                  defaultValue="100%"
                />
              </fieldset>
            ))}
          </div>
          <Popover.Arrow className="fill-white" />
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}

Tabs

import * as Tabs from "@radix-ui/react-tabs";

export function AccountTabs() {
  return (
    <Tabs.Root defaultValue="account" className="w-full max-w-lg">
      <Tabs.List className="flex border-b" aria-label="Manage your account">
        <Tabs.Trigger
          value="account"
          className="px-4 py-2 text-sm border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 hover:text-gray-700"
        >
          Account
        </Tabs.Trigger>
        <Tabs.Trigger
          value="password"
          className="px-4 py-2 text-sm border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 hover:text-gray-700"
        >
          Password
        </Tabs.Trigger>
      </Tabs.List>
      <Tabs.Content value="account" className="p-4">
        <p className="text-sm text-gray-600">
          Make changes to your account here. Click save when you are done.
        </p>
        <div className="mt-4 space-y-3">
          <label className="block text-sm font-medium">Name</label>
          <input className="w-full rounded border px-3 py-2 text-sm" defaultValue="Pedro Duarte" />
        </div>
      </Tabs.Content>
      <Tabs.Content value="password" className="p-4">
        <p className="text-sm text-gray-600">
          Change your password here. After saving, you will be logged out.
        </p>
        <div className="mt-4 space-y-3">
          <label className="block text-sm font-medium">Current password</label>
          <input className="w-full rounded border px-3 py-2 text-sm" type="password" />
          <label className="block text-sm font-medium">New password</label>
          <input className="w-full rounded border px-3 py-2 text-sm" type="password" />
        </div>
      </Tabs.Content>
    </Tabs.Root>
  );
}

Toast Notifications

import * as Toast from "@radix-ui/react-toast";
import { useState, useRef } from "react";
import { X } from "lucide-react";

export function ToastDemo() {
  const [open, setOpen] = useState(false);
  const timerRef = useRef(0);

  function handleClick() {
    setOpen(false);
    window.clearTimeout(timerRef.current);
    timerRef.current = window.setTimeout(() => setOpen(true), 100);
  }

  return (
    <Toast.Provider swipeDirection="right">
      <button onClick={handleClick} className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">
        Save Changes
      </button>
      <Toast.Root
        className="rounded-lg bg-white p-4 shadow-lg border flex items-center justify-between data-[state=open]:animate-slideIn data-[state=closed]:animate-fadeOut"
        open={open}
        onOpenChange={setOpen}
      >
        <div>
          <Toast.Title className="text-sm font-semibold">Changes saved</Toast.Title>
          <Toast.Description className="mt-1 text-xs text-gray-500">
            Your profile has been updated successfully.
          </Toast.Description>
        </div>
        <Toast.Close aria-label="Close">
          <X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
        </Toast.Close>
      </Toast.Root>
      <Toast.Viewport className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-96" />
    </Toast.Provider>
  );
}

Controlled vs Uncontrolled

import * as Dialog from "@radix-ui/react-dialog";
import { useState } from "react";

// Uncontrolled: Radix manages open/close state internally
function UncontrolledDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Open</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Content>...</Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

// Controlled: you manage the state
function ControlledDialog() {
  const [open, setOpen] = useState(false);

  // You can open the dialog programmatically
  async function handleSave() {
    await saveData();
    setOpen(false); // close after async operation
  }

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger>Open</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Content>
          <button onClick={handleSave}>Save & Close</button>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Best Practices

  1. Always use asChild on Trigger components when you want to render your own button element. This merges Radix behavior onto your element without adding an extra DOM node.
  2. Use Portal for overlays (Dialog, Popover, Dropdown) to render content at the end of the document body, avoiding z-index and overflow issues.
  3. Use data attributes for styling -- Radix exposes data-[state=open], data-[state=closed], data-[side=top], etc. These are stable API and ideal for conditional styling.
  4. Wrap primitives in your own components to create a consistent API across your app. Export your styled version from a ui/ directory.
  5. Use the forceMount prop when you need to control mount/unmount with your own animation library (e.g., Framer Motion).
  6. Provide sideOffset and align on Content components to control positioning relative to the trigger.
  7. Handle onEscapeKeyDown and onPointerDownOutside to customize dismiss behavior when needed.

Anti-Patterns

  1. Adding onClick to Trigger to manually toggle state when using uncontrolled mode. Let Radix handle it. If you need control, use the controlled pattern with open and onOpenChange.
  2. Nesting portaled components incorrectly -- a Dropdown inside a Dialog must be portaled to the Dialog content, not the document body. Use container prop when nesting.
  3. Forgetting Dialog.Title and Dialog.Description -- these are required for accessibility. If you visually hide them, use VisuallyHidden from @radix-ui/react-visually-hidden.
  4. Using e.stopPropagation() inside Radix components -- this can break internal event handling. Use Radix's own event handler props like onPointerDownOutside instead.
  5. Re-implementing keyboard navigation that Radix already provides. Arrow keys in menus, Escape to close, Tab trapping in dialogs -- these all work automatically.
  6. Ignoring the asChild pattern and wrapping triggers in extra divs or spans, which breaks accessibility and adds unnecessary DOM elements.

Install this skill directly: skilldb add ui-components-services-skills

Get CLI access →