React Design System: Radix UI, Tailwind, Storybook, and Design Tokens
Build a production React design system with Radix UI primitives, Tailwind CSS, Storybook documentation, and design tokens. Covers component architecture, theming, accessibility, and monorepo packaging.
Every team eventually reaches the point where they're copying the same Button component across three apps and maintaining slightly different variants in each. A design system solves this โ not a UI kit you bought, but your own component library that encodes your product's design decisions, accessibility requirements, and interaction patterns.
This guide builds a production-grade design system: Radix UI primitives for accessibility, Tailwind for styling, design tokens for theming, and Storybook for documentation.
Project Structure
packages/ui/
โโโ src/
โ โโโ components/
โ โ โโโ button/
โ โ โ โโโ button.tsx
โ โ โ โโโ button.stories.tsx
โ โ โ โโโ index.ts
โ โ โโโ dialog/
โ โ โโโ dropdown-menu/
โ โ โโโ input/
โ โ โโโ select/
โ โ โโโ toast/
โ โ โโโ tooltip/
โ โโโ tokens/
โ โ โโโ colors.ts
โ โ โโโ spacing.ts
โ โ โโโ typography.ts
โ โโโ lib/
โ โ โโโ cn.ts # clsx + twMerge helper
โ โโโ index.ts # Public exports
โโโ .storybook/
โ โโโ main.ts
โ โโโ preview.tsx
โโโ package.json
โโโ tailwind.config.ts
Design Tokens
// packages/ui/src/tokens/colors.ts
// Semantic color tokens โ map to Tailwind CSS variables
export const colorTokens = {
// Brand
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// UI surfaces
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
// State
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
// Borders & inputs
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
// Cards & popovers
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
} as const;
/* packages/ui/src/globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--warning: 37.7 92.1% 50.2%;
--warning-foreground: 26 83.3% 14.1%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
}
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
The cn Utility
// packages/ui/src/lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
// Merge Tailwind classes without conflicts
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
Button Component
// packages/ui/src/components/button/button.tsx
import { forwardRef } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/cn";
const buttonVariants = cva(
// Base styles applied to every variant
[
"inline-flex items-center justify-center gap-2",
"rounded-[--radius] text-sm font-medium",
"ring-offset-background transition-all duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"active:scale-[0.98]",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
],
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-muted text-muted-foreground hover:bg-muted/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "bg-success text-success-foreground hover:bg-success/90 shadow-sm",
},
size: {
xs: "h-7 px-2.5 text-xs",
sm: "h-8 px-3",
default: "h-10 px-4 py-2",
lg: "h-11 px-6 text-base",
xl: "h-12 px-8 text-base",
icon: "h-10 w-10",
"icon-sm": "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean; // Render as child element (e.g., <a> or <Link>)
loading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
disabled={disabled || loading}
aria-busy={loading}
{...props}
>
{loading ? (
<>
<svg
className="animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>{children}</span>
</>
) : (
children
)}
</Comp>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Input Component
// packages/ui/src/components/input/input.tsx
import { forwardRef } from "react";
import { cn } from "../../lib/cn";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-[--radius] border border-input bg-background px-3 py-2",
"text-sm placeholder:text-muted-foreground",
"ring-offset-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
error && "border-destructive focus-visible:ring-destructive",
className
)}
ref={ref}
aria-invalid={error ? "true" : undefined}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
// packages/ui/src/components/input/form-field.tsx
// Composable form field wrapper
import { cn } from "../../lib/cn";
interface FormFieldProps {
label: string;
htmlFor: string;
error?: string;
hint?: string;
required?: boolean;
children: React.ReactNode;
className?: string;
}
export function FormField({
label,
htmlFor,
error,
hint,
required,
children,
className,
}: FormFieldProps) {
return (
<div className={cn("space-y-1.5", className)}>
<label
htmlFor={htmlFor}
className="text-sm font-medium text-foreground leading-none"
>
{label}
{required && (
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
)}
</label>
{children}
{hint && !error && (
<p className="text-xs text-muted-foreground">{hint}</p>
)}
{error && (
<p className="text-xs text-destructive" role="alert" id={`${htmlFor}-error`}>
{error}
</p>
)}
</div>
);
}
Dialog Component (Radix UI)
// packages/ui/src/components/dialog/dialog.tsx
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../../lib/cn";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) => (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
);
const DialogContent = ({
className,
children,
showClose = true,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean;
}) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%]",
"bg-card text-card-foreground shadow-xl rounded-xl border border-border",
"p-6 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%]",
className
)}
{...props}
>
{children}
{showClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-left mb-4", className)} {...props} />
);
const DialogTitle = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) => (
<DialogPrimitive.Title className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
);
const DialogDescription = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) => (
<DialogPrimitive.Description className={cn("text-sm text-muted-foreground", className)} {...props} />
);
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-6", className)} {...props} />
);
export {
Dialog, DialogTrigger, DialogContent, DialogClose,
DialogHeader, DialogTitle, DialogDescription, DialogFooter,
};
Storybook Setup
// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-a11y", // Accessibility checks
"@storybook/addon-themes", // Dark mode toggle
"@chromatic-com/storybook", // Visual regression testing
],
framework: { name: "@storybook/react-vite", options: {} },
docs: { autodocs: "tag" },
};
export default config;
// .storybook/preview.tsx
import type { Preview } from "@storybook/react";
import "../src/globals.css";
const preview: Preview = {
parameters: {
backgrounds: { disable: true },
controls: { matchers: { color: /(background|color)$/i, date: /date/i } },
},
decorators: [
(Story, context) => {
const theme = context.globals.theme ?? "light";
return (
<div className={theme} style={{ padding: "2rem", minHeight: "100vh", background: "hsl(var(--background))" }}>
<Story />
</div>
);
},
],
globalTypes: {
theme: {
description: "Global theme",
defaultValue: "light",
toolbar: {
title: "Theme",
icon: "circlehollow",
items: ["light", "dark"],
dynamicTitle: true,
},
},
},
};
export default preview;
Button Stories
// packages/ui/src/components/button/button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { Button } from "./button";
const meta = {
title: "Components/Button",
component: Button,
parameters: { layout: "centered" },
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "success"],
},
size: {
control: "select",
options: ["xs", "sm", "default", "lg", "xl", "icon"],
},
loading: { control: "boolean" },
disabled: { control: "boolean" },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: "Click me", variant: "default", size: "default" },
};
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap gap-3 items-center">
<Button variant="default">Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="success">Success</Button>
</div>
),
};
export const WithIcon: Story = {
render: () => (
<div className="flex gap-3 items-center">
<Button><Plus /> Create project</Button>
<Button variant="destructive"><Trash2 /> Delete</Button>
<Button variant="outline" size="icon"><Plus /></Button>
</div>
),
};
export const Loading: Story = {
args: { children: "Saving...", loading: true },
};
export const AllSizes: Story = {
render: () => (
<div className="flex gap-3 items-center">
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra large</Button>
</div>
),
};
// Accessibility: keyboard navigation, focus rings, ARIA
export const AccessibilityTest: Story = {
args: { children: "Accessible button", "aria-label": "Accessible button with clear label" },
parameters: {
a11y: { config: { rules: [{ id: "color-contrast", enabled: true }] } },
},
};
Package Exports
// packages/ui/src/index.ts
// Components
export * from "./components/button/button";
export * from "./components/input/input";
export * from "./components/input/form-field";
export * from "./components/dialog/dialog";
export * from "./components/dropdown-menu/dropdown-menu";
export * from "./components/select/select";
export * from "./components/toast/toast";
export * from "./components/tooltip/tooltip";
// Utilities
export { cn } from "./lib/cn";
// Tokens (for use in consuming apps' Tailwind configs)
export { colorTokens } from "./tokens/colors";
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.1.0",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./globals.css": "./src/globals.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --external react",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.400.0",
"tailwind-merge": "^2.3.0"
}
}
Consuming in Apps
// apps/web/tailwind.config.ts
import type { Config } from "tailwindcss";
import { colorTokens } from "@acme/ui";
export default {
content: [
"./src/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}", // Include UI package for Tailwind scanning
],
theme: {
extend: {
colors: colorTokens,
borderRadius: {
DEFAULT: "var(--radius)",
sm: "calc(var(--radius) - 4px)",
lg: "calc(var(--radius) + 4px)",
},
},
},
} satisfies Config;
// apps/web/src/app/layout.tsx
import "@acme/ui/globals.css"; // Import design tokens
import "./globals.css";
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Core component library (Button, Input, Dialog, 5โ10 components) | 1 dev | 1โ2 weeks | $3,000โ6,000 |
| Full design system (20+ components + Storybook + dark mode) | 1โ2 devs | 3โ5 weeks | $8,000โ18,000 |
| Enterprise (design tokens, Figma sync, visual regression CI) | 2โ3 devs | 6โ10 weeks | $20,000โ45,000 |
| Ongoing maintenance (per month) | 0.5 dev | Ongoing | $1,500โ3,000/mo |
Key decisions made here:
- Radix UI for all interactive primitives โ keyboard nav, ARIA, and focus management handled automatically
- CVA (class-variance-authority) for variant management โ cleaner than conditional class strings
- CSS variables for theming โ dark mode works without JavaScript, one token change updates everything
- Storybook addon-a11y โ accessibility checked on every story automatically
See Also
- Next.js Monorepo with Turborepo
- React Compound Components Pattern
- TypeScript Utility Types for React
- React Testing Library Patterns
- Next.js Performance Optimization
Working With Viprasol
Building a design system is an investment that pays off when you're maintaining multiple apps or onboarding developers who need consistent UI fast. Our team builds design systems that are actually used โ well-documented in Storybook, accessible out of the box, and structured so your team can extend them without breaking things.
What we deliver:
- Radix UI primitive-based component library (Button, Input, Dialog, Select, Toast, Dropdown, Tooltip, and more)
- Design token system with CSS variables for light/dark mode
- Storybook with accessibility addon and visual regression via Chromatic
- CVA variant system for clean, type-safe component APIs
- Monorepo package setup ready for consumption across apps
Talk to our team about building your design system โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.