Blog/Tutorials & Step-by-Step/Create a Design System with Tailwind CSS and Radix UI
POST
February 10, 2026
LAST UPDATEDFebruary 10, 2026

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

Design SystemTailwind CSSRadix UIComponents
Create a Design System with Tailwind CSS and Radix UI
4 min read

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.

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

The 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

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

css
/* 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.

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

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

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

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

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

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

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

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

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

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

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

  1. Design tokens -- CSS custom properties in tokens.css define colors, spacing, and radii that can be swapped for theming
  2. Utility layer -- cn() merges Tailwind classes with proper conflict resolution
  3. Variant layer -- cva defines typed style variants per component
  4. Primitive layer -- Radix UI provides accessible behavior without styling
  5. 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.

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.