Create a Design System with Tailwind CSS and Radix UI
Build a scalable design system from scratch using Tailwind CSS for styling and Radix UI primitives for accessible, unstyled components, with token management and Storybook docs.
Tags
Create a Design System with Tailwind CSS and Radix UI
TL;DR
Radix UI provides accessible, unstyled primitives while Tailwind CSS handles the visual layer -- together they let you build a design system that is both fully accessible and completely customizable without fighting pre-built styles.
Prerequisites
- ›React 18+ with TypeScript
- ›Familiarity with Tailwind CSS utility classes
- ›Node.js 18+ installed
- ›Basic understanding of component library concepts
Step 1: Project Setup
Initialize the design system package. We will use a standalone package structure that can later be published to npm or used in a monorepo.
mkdir ui-library && cd ui-library
npm init -y
npm install react react-dom
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu \
@radix-ui/react-select @radix-ui/react-tabs @radix-ui/react-tooltip \
@radix-ui/react-switch @radix-ui/react-checkbox
npm install tailwindcss class-variance-authority clsx tailwind-merge
npm install -D typescript @types/react @types/react-domThe key libraries:
- ›Radix UI -- unstyled, accessible component primitives
- ›class-variance-authority (cva) -- type-safe variant patterns for component styling
- ›clsx + tailwind-merge -- conditional class merging that handles Tailwind specificity conflicts
Configure Tailwind
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;CSS Variables for Theming
Define your design tokens as CSS custom properties so themes can be swapped by changing variable values.
/* src/styles/tokens.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--primary: 222 47% 11%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214 32% 91%;
--radius: 0.5rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
}
}Step 2: Create the Utility Function
Build a helper that merges class names intelligently, resolving Tailwind conflicts.
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}This cn function is used throughout every component. It allows consumers to override default styles without worrying about CSS specificity. For example, cn("px-4 py-2", "px-8") correctly resolves to px-8 py-2 instead of keeping both padding utilities.
Step 3: Build a Button Component with Variants
The Button is the foundation of any design system. Use cva (class-variance-authority) to define typed variant patterns.
// src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
const buttonVariants = cva(
// Base styles applied to all variants
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-background hover:bg-secondary hover:text-secondary-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-secondary hover:text-secondary-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };Usage:
<Button>Default</Button>
<Button variant="destructive" size="lg">Delete</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost" size="icon"><SearchIcon /></Button>Why cva?
Without cva, you end up with verbose conditional class logic:
// Without cva - messy and error-prone
const classes = `base-classes ${variant === "destructive" ? "bg-red-500" : variant === "outline" ? "border border-gray-300" : "bg-blue-500"}`;cva provides type-safe variants with autocompletion, default values, and clean composition.
Step 4: Build a Dialog Component with Radix
Radix Dialog handles focus trapping, scroll locking, escape key dismissal, and screen reader announcements. You just add styles.
// src/components/Dialog.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = "DialogContent";
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
const DialogTitle = forwardRef<
HTMLHeadingElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";
const DialogDescription = forwardRef<
HTMLParagraphElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = "DialogDescription";
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
};Usage:
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile information below.</DialogDescription>
</DialogHeader>
<form>
<input type="text" placeholder="Name" className="w-full border rounded px-3 py-2" />
<Button type="submit" className="mt-4">Save Changes</Button>
</form>
</DialogContent>
</Dialog>The asChild prop from Radix lets you compose the trigger with any element (like our Button) instead of rendering an extra DOM node.
Step 5: Build a Select Component
Radix Select provides keyboard navigation, typeahead search, and proper ARIA attributes for dropdown selection.
// src/components/Select.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "../lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon>
<span className="text-muted-foreground">▾</span>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = "SelectTrigger";
const SelectContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = "SelectContent";
const SelectItem = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = "SelectItem";
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };Usage:
<Select>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>Step 6: Build a Tabs Component
// src/components/Tabs.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };Step 7: Export the Component Library
Create a barrel export file and configure the package for consumption.
// src/index.ts
export { Button, buttonVariants } from "./components/Button";
export type { ButtonProps } from "./components/Button";
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
} from "./components/Dialog";
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
} from "./components/Select";
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/Tabs";
export { cn } from "./lib/utils";Update package.json for publishing:
{
"name": "@myorg/ui",
"version": "0.1.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./styles": "./src/styles/tokens.css"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}Putting It All Together
Your design system architecture follows a clear separation of concerns:
- ›Design tokens -- CSS custom properties in
tokens.cssdefine colors, spacing, and radii that can be swapped for theming - ›Utility layer --
cn()merges Tailwind classes with proper conflict resolution - ›Variant layer --
cvadefines typed style variants per component - ›Primitive layer -- Radix UI provides accessible behavior without styling
- ›Component layer -- your components compose Radix primitives with Tailwind styles and cva variants
Consumers import components and the token stylesheet. Theming is as simple as changing CSS variable values on the root element or adding a .dark class.
Next Steps
- ›Add Storybook -- document each component with interactive stories showing all variant combinations
- ›Build a form library -- create Input, Label, Textarea, and Form components that integrate with React Hook Form
- ›Add animation -- integrate Tailwind CSS animation utilities or Framer Motion for enter/exit transitions
- ›Publish to npm -- use tsup for bundling and changesets for versioned releases
- ›Add a toast/notification component -- use Radix Toast primitive with auto-dismiss and stacking behavior
FAQ
Why use Radix UI instead of building components from scratch?
Radix UI handles complex accessibility patterns like focus management, keyboard navigation, and ARIA attributes that are extremely difficult to implement correctly. By starting with Radix primitives, you get WAI-ARIA compliant components and only need to add your visual styling layer.
How do you manage design tokens with Tailwind CSS?
Define your design tokens (colors, spacing, typography) in tailwind.config.ts under the theme.extend object. This creates a single source of truth that generates utility classes, ensuring every component uses consistent values from your token system.
Should a design system be published as an npm package?
For teams sharing components across multiple projects, publishing to npm or a private registry is the standard approach. Use a monorepo with a dedicated packages/ui directory, build with tsup or Rollup, and publish with changesets for versioned releases.
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.