Blog/Tutorials & Step-by-Step/Building a Component Library with Radix UI
POST
March 12, 2026
LAST UPDATEDMarch 12, 2026

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

ReactUI/UXTailwind
Building a Component Library with Radix UI
4 min read

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:

bash
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-merge

Create a class merging utility:

typescript
// 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.

tsx
// 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">&times;</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:

tsx
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-labelledby and aria-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.

tsx
// 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:

tsx
<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.

tsx
// 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">&#9662;</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:

tsx
<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.

tsx
// 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:

tsx
<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.

tsx
// __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.

json
{
  "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:

typescript
// 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:

bash
npm install -D tsup

Putting It All Together

Your component library now follows a clear architecture:

  1. Radix primitives provide accessible behavior (focus trapping, keyboard navigation, ARIA)
  2. Tailwind CSS handles the visual styling through utility classes
  3. forwardRef + cn() enable style customization by consumers
  4. Compound components maintain clean APIs with logical sub-component grouping
  5. 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-tooltip with 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.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

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
Mar 21, 20266 min read
Next.js
Node.js
BFF

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
Mar 21, 20265 min read
CI/CD
Next.js
Docker

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.