Back to Blog

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.

Viprasol Tech Team
March 26, 2027
14 min read

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

ScopeTeamTimelineCost Range
Core component library (Button, Input, Dialog, 5โ€“10 components)1 dev1โ€“2 weeks$3,000โ€“6,000
Full design system (20+ components + Storybook + dark mode)1โ€“2 devs3โ€“5 weeks$8,000โ€“18,000
Enterprise (design tokens, Figma sync, visual regression CI)2โ€“3 devs6โ€“10 weeks$20,000โ€“45,000
Ongoing maintenance (per month)0.5 devOngoing$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


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.