Radix UI
"Radix UI: accessible primitives, Dialog, Dropdown, Popover, Tabs, Toast, composition pattern, styling with className, controlled/uncontrolled"
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 linesRadix 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
- Always use
asChildon Trigger components when you want to render your own button element. This merges Radix behavior onto your element without adding an extra DOM node. - Use
Portalfor overlays (Dialog, Popover, Dropdown) to render content at the end of the document body, avoiding z-index and overflow issues. - 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. - Wrap primitives in your own components to create a consistent API across your app. Export your styled version from a
ui/directory. - Use the
forceMountprop when you need to control mount/unmount with your own animation library (e.g., Framer Motion). - Provide
sideOffsetandalignon Content components to control positioning relative to the trigger. - Handle
onEscapeKeyDownandonPointerDownOutsideto customize dismiss behavior when needed.
Anti-Patterns
- Adding
onClickto Trigger to manually toggle state when using uncontrolled mode. Let Radix handle it. If you need control, use the controlled pattern withopenandonOpenChange. - Nesting portaled components incorrectly -- a Dropdown inside a Dialog must be portaled to the Dialog content, not the document body. Use
containerprop when nesting. - Forgetting Dialog.Title and Dialog.Description -- these are required for accessibility. If you visually hide them, use
VisuallyHiddenfrom@radix-ui/react-visually-hidden. - Using
e.stopPropagation()inside Radix components -- this can break internal event handling. Use Radix's own event handler props likeonPointerDownOutsideinstead. - Re-implementing keyboard navigation that Radix already provides. Arrow keys in menus, Escape to close, Tab trapping in dialogs -- these all work automatically.
- Ignoring the
asChildpattern 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
Related Skills
Ark UI
"Ark UI: state machine-driven components, headless primitives, Select, Combobox, DatePicker, Toast, styling with any CSS framework"
Daisyui
"daisyUI: Tailwind CSS component library, themes, responsive utilities, component classes, customization, semantic color names"
Headless UI
"Headless UI: unstyled accessible components for Tailwind, Dialog, Combobox, Listbox, Menu, Transition, render props, React/Vue"
Kobalte
Kobalte: accessible, unstyled UI component library for SolidJS with WAI-ARIA compliance and composable compound component APIs
Melt UI
Melt UI: headless, accessible UI component builders for Svelte using the builder pattern with full styling freedom
Park UI
Park UI: pre-designed styled components built on Ark UI primitives, available for React, Vue, and Solid with Panda CSS or Tailwind CSS styling