Loading…
Loading…
Learn how to build an accessible, highly customizable React component library using Radix UI primitives and Tailwind CSS.
Tags
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.
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?
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));
}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>
);
}Without writing a single line of accessibility code, this modal:
aria-labelledby and aria-describedbyRadix 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>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>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>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();
});
});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 tsupYour component library now follows a clear architecture:
Every component is accessible by default. Consumers get full styling control through className props without needing to override pre-built CSS.
@radix-ui/react-tooltip with styled content and arrow positioningHeadless 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.
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.
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
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.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
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.
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.