Building a Component Library with Radix UI
Learn how to build an accessible, highly customizable React component library using Radix UI primitives and Tailwind CSS.
Tags
Building a Component Library with Radix UI
TL;DR
Radix UI primitives give you fully accessible component behavior (focus trapping, keyboard navigation, screen reader support) out of the box, letting you focus purely on styling with Tailwind CSS.
Prerequisites
- ›React 18+ with TypeScript
- ›Tailwind CSS configured in your project
- ›Node.js 18+ installed
- ›Familiarity with React component patterns (forwardRef, composition)
Step 1: Understanding Headless UI Primitives
Headless components provide behavior and accessibility without any visual styling. Radix UI is a collection of these primitives covering dialogs, dropdowns, tooltips, tabs, and more.
Why headless over pre-styled libraries?
- ›Full design control -- no fighting with opinionated CSS or theme overrides
- ›Accessibility built in -- WAI-ARIA patterns, focus management, and keyboard navigation handled for you
- ›Composition friendly -- each primitive exposes sub-components you can style and extend independently
Install the primitives you need:
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu \
@radix-ui/react-tooltip @radix-ui/react-accordion @radix-ui/react-popover
npm install clsx tailwind-mergeCreate a class merging utility:
// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Step 2: Build a Modal Component from Radix Dialog
Radix Dialog handles focus trapping, scroll locking, escape key dismissal, and ARIA attributes. We wrap it with Tailwind styles.
// components/Modal.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "../lib/utils";
// Re-export the root and trigger directly
export const ModalRoot = Dialog.Root;
export const ModalTrigger = Dialog.Trigger;
export const ModalOverlay = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof Dialog.Overlay>
>(({ className, ...props }, ref) => (
<Dialog.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className
)}
{...props}
/>
));
ModalOverlay.displayName = "ModalOverlay";
export const ModalContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof Dialog.Content>
>(({ className, children, ...props }, ref) => (
<Dialog.Portal>
<ModalOverlay />
<Dialog.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-md rounded-xl bg-white p-6 shadow-2xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"focus:outline-none",
className
)}
{...props}
>
{children}
<Dialog.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2">
<span className="text-xl">×</span>
<span className="sr-only">Close</span>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
));
ModalContent.displayName = "ModalContent";
export const ModalTitle = forwardRef<
HTMLHeadingElement,
ComponentPropsWithoutRef<typeof Dialog.Title>
>(({ className, ...props }, ref) => (
<Dialog.Title
ref={ref}
className={cn("text-lg font-semibold text-gray-900", className)}
{...props}
/>
));
ModalTitle.displayName = "ModalTitle";
export const ModalDescription = forwardRef<
HTMLParagraphElement,
ComponentPropsWithoutRef<typeof Dialog.Description>
>(({ className, ...props }, ref) => (
<Dialog.Description
ref={ref}
className={cn("mt-2 text-sm text-gray-500", className)}
{...props}
/>
));
ModalDescription.displayName = "ModalDescription";Usage:
import { ModalRoot, ModalTrigger, ModalContent, ModalTitle, ModalDescription } from "./Modal";
function DeleteConfirmation() {
return (
<ModalRoot>
<ModalTrigger asChild>
<button className="px-4 py-2 bg-red-500 text-white rounded">Delete Item</button>
</ModalTrigger>
<ModalContent>
<ModalTitle>Delete this item?</ModalTitle>
<ModalDescription>
This action cannot be undone. The item will be permanently removed.
</ModalDescription>
<div className="mt-6 flex justify-end gap-3">
<Dialog.Close asChild>
<button className="px-4 py-2 border rounded">Cancel</button>
</Dialog.Close>
<button className="px-4 py-2 bg-red-500 text-white rounded">Delete</button>
</div>
</ModalContent>
</ModalRoot>
);
}What Radix Handles Automatically
Without writing a single line of accessibility code, this modal:
- ›Traps focus inside the dialog when open
- ›Returns focus to the trigger when closed
- ›Closes on Escape key press
- ›Prevents scroll on the body while open
- ›Announces the title and description to screen readers via
aria-labelledbyandaria-describedby - ›Renders in a portal to avoid z-index stacking issues
Step 3: Build a Dropdown Menu
Radix DropdownMenu provides keyboard navigation, typeahead search, sub-menus, and proper ARIA roles.
// components/DropdownMenu.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "../lib/utils";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-lg",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = "DropdownMenuContent";
export const DropdownMenuItem = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { destructive?: boolean }
>(({ className, destructive, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
"focus:bg-gray-100 focus:text-gray-900",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
destructive && "text-red-600 focus:bg-red-50 focus:text-red-700",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropdownMenuSeparator = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-gray-200", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
export const DropdownMenuLabel = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-semibold text-gray-500", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = "DropdownMenuLabel";Usage:
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-2 rounded hover:bg-gray-100">...</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>Step 4: Build an Accordion Component
Accordions are common for FAQ sections and settings panels. Radix handles the expand/collapse logic and ARIA attributes.
// components/Accordion.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "../lib/utils";
export const Accordion = AccordionPrimitive.Root;
export const AccordionItem = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-gray-200", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
export const AccordionTrigger = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all",
"hover:underline [&[data-state=open]>span.chevron]:rotate-180",
className
)}
{...props}
>
{children}
<span className="chevron transition-transform duration-200">▾</span>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = "AccordionTrigger";
export const AccordionContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up",
className
)}
{...props}
>
<div className="pb-4 pt-0">{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = "AccordionContent";Usage:
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. Radix handles ARIA attributes and keyboard navigation automatically.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Can I customize the styles?</AccordionTrigger>
<AccordionContent>
Absolutely. Every sub-component accepts a className prop for Tailwind classes.
</AccordionContent>
</AccordionItem>
</Accordion>Step 5: Compound Component Patterns
Radix components follow the compound component pattern where a root component provides context and sub-components consume it. When building your own components on top, maintain this pattern.
// components/Card.tsx
import { createContext, useContext, type ReactNode } from "react";
import { cn } from "../lib/utils";
interface CardContextValue {
variant: "default" | "outlined" | "elevated";
}
const CardContext = createContext<CardContextValue>({ variant: "default" });
interface CardProps {
variant?: "default" | "outlined" | "elevated";
className?: string;
children: ReactNode;
}
export function Card({ variant = "default", className, children }: CardProps) {
return (
<CardContext.Provider value={{ variant }}>
<div
className={cn(
"rounded-lg",
variant === "default" && "bg-white border border-gray-200",
variant === "outlined" && "bg-transparent border-2 border-gray-300",
variant === "elevated" && "bg-white shadow-lg",
className
)}
>
{children}
</div>
</CardContext.Provider>
);
}
export function CardHeader({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("px-6 py-4 border-b border-gray-100", className)}>{children}</div>;
}
export function CardTitle({ className, children }: { className?: string; children: ReactNode }) {
return <h3 className={cn("text-lg font-semibold text-gray-900", className)}>{children}</h3>;
}
export function CardContent({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("px-6 py-4", className)}>{children}</div>;
}
export function CardFooter({ className, children }: { className?: string; children: ReactNode }) {
return (
<div className={cn("px-6 py-4 border-t border-gray-100 flex justify-end gap-2", className)}>
{children}
</div>
);
}Usage:
<Card variant="elevated">
<CardHeader>
<CardTitle>Project Settings</CardTitle>
</CardHeader>
<CardContent>
<p>Configure your project settings here.</p>
</CardContent>
<CardFooter>
<button className="px-4 py-2 border rounded">Cancel</button>
<button className="px-4 py-2 bg-blue-500 text-white rounded">Save</button>
</CardFooter>
</Card>Step 6: Accessibility Testing
Verify that your components meet WCAG standards by testing keyboard navigation and screen reader behavior.
// __tests__/Modal.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ModalRoot, ModalTrigger, ModalContent, ModalTitle } from "../components/Modal";
describe("Modal", () => {
it("opens on trigger click and traps focus", async () => {
const user = userEvent.setup();
render(
<ModalRoot>
<ModalTrigger>Open</ModalTrigger>
<ModalContent>
<ModalTitle>Test Modal</ModalTitle>
<input data-testid="modal-input" />
</ModalContent>
</ModalRoot>
);
await user.click(screen.getByText("Open"));
expect(screen.getByText("Test Modal")).toBeInTheDocument();
// Tab should cycle within modal
await user.tab();
const input = screen.getByTestId("modal-input");
expect(input).toHaveFocus();
});
it("closes on Escape key", async () => {
const user = userEvent.setup();
render(
<ModalRoot>
<ModalTrigger>Open</ModalTrigger>
<ModalContent>
<ModalTitle>Test Modal</ModalTitle>
</ModalContent>
</ModalRoot>
);
await user.click(screen.getByText("Open"));
expect(screen.getByText("Test Modal")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByText("Test Modal")).not.toBeInTheDocument();
});
});Step 7: Packaging for npm
Structure your library for distribution with proper exports and type declarations.
{
"name": "@myorg/ui",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tailwindcss": "^3.0.0"
}
}Create the barrel export:
// src/index.ts
export { ModalRoot, ModalTrigger, ModalContent, ModalTitle, ModalDescription } from "./components/Modal";
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from "./components/DropdownMenu";
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./components/Accordion";
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from "./components/Card";
export { cn } from "./lib/utils";Install tsup for building:
npm install -D tsupPutting It All Together
Your component library now follows a clear architecture:
- ›Radix primitives provide accessible behavior (focus trapping, keyboard navigation, ARIA)
- ›Tailwind CSS handles the visual styling through utility classes
- ›forwardRef + cn() enable style customization by consumers
- ›Compound components maintain clean APIs with logical sub-component grouping
- ›tsup bundles everything for npm distribution
Every component is accessible by default. Consumers get full styling control through className props without needing to override pre-built CSS.
Next Steps
- ›Add Storybook -- create interactive documentation for each component and its variants
- ›Implement a Tooltip component -- wrap
@radix-ui/react-tooltipwith styled content and arrow positioning - ›Add animation -- use Tailwind animation utilities or Framer Motion for enter/exit transitions on dropdowns and modals
- ›Set up Changesets -- automate versioning and changelog generation for your published package
- ›Add a theme provider -- create a context that toggles between light and dark mode CSS variables
FAQ
What are headless UI components?
Headless UI components provide all the behavior, accessibility, and state management for complex UI elements like modals and dropdowns, without imposing any visual styling, giving you complete design freedom.
Why choose Radix UI over building components from scratch?
Radix handles complex accessibility requirements like focus trapping, keyboard navigation, and screen reader announcements automatically, which are extremely difficult and time-consuming to implement correctly from scratch.
How do you style Radix UI components with Tailwind CSS?
Wrap Radix primitives in your own components and apply Tailwind classes directly to the Radix elements, using data attributes provided by Radix for state-based styling like open/closed states.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.