Command Palette

Search for a command to run...

How to Build a Modern Design System with React and Tailwind CSS: From Figma to Code

How to Build a Modern Design System with React and Tailwind CSS: From Figma to Code

Step-by-step guide to translate Figma tokens into a production-grade React + Tailwind CSS design system with theming, accessibility, Storybook, and CI/CD.

9 min read·Design Systems

Design systems help teams ship consistent, accessible, and fast interfaces. In this guide you’ll learn how to go from Figma to code: define tokens in Figma, export them, wire them into Tailwind CSS, and build a React component library with headless primitives (e.g., Radix UI). We’ll also cover theming, accessibility, Storybook docs, testing, and release workflows—so your system is practical in production.

You’ll build: a token pipeline (Figma → JSON → CSS variables), a Tailwind-powered theme, reusable React components (Button, Input, Dialog), and a workflow that scales with teams.


Table of Contents

  1. Prerequisites & Tooling
  2. Design Tokens in Figma (Naming & Variables)
  3. Exporting Tokens to Code (JSON → CSS Vars → Tailwind)
  4. Tailwind Configuration for Tokens & Themes
  5. Building Accessible React Primitives (Button, Input, Dialog)
  6. Theming & Dark Mode (CSS Variables + Data Attributes)
  7. Documentation with Storybook (MDX) and Live Examples
  8. Testing (Unit, A11y, and Visual Regression)
  9. Packaging, Versioning, and CI/CD
  10. Reference Folder Structure
  11. FAQ: Design System Decisions

Prerequisites & Tooling

  • Figma with Variables enabled (color, typography, spacing, radii, shadows).
  • Optional plugins: Tokens Studio, Figma Tokens (community), or your preferred exporter.
  • Node.js + pnpm for package management.
  • Tailwind CSS for utility-first styling.
  • Radix UI (or similar headless primitives) for accessible behaviors.
  • Storybook for documentation and component testing.
  • Jest/RTL, Playwright, and a visual regression tool for tests.
  • Optional build tooling: tsup or Vite (library mode) for package builds.

Goal: a pipeline where designers change tokens in Figma, and engineers regenerate code assets with minimal drift.


Design Tokens in Figma (Naming & Variables)

Well-named tokens make or break your system. Prefer semantic tokens that describe intent over hard-coded values.

Token categories to define in Figma:

  • color: surface, background, content, primary, secondary, success, warning, danger.
  • space: 0, 1, 2, 3, 4, 5, 6… (mapped to 0, 4, 8, 12, 16, 20, 24…px).
  • radius: none, sm, md, lg, full.
  • font: families, sizes, weights, line-heights, letter-spacing.
  • shadow/elevation: levels for cards, popovers, modals.
  • motion: durations, easings (e.g., standard, emphasized, snappy).
  • z-index & breakpoints (document these; code will own the actual breakpoints).

Naming pattern (semantics + scale):

color.bg/default
color.bg/raised
color.fg/muted
color.brand/primary
radius/sm
space/3
shadow/lg
motion/duration/fast

Theme strategy: Keep aliases that map to raw values per theme. Example:

color.fg/default → {light: #0b0c0f, dark: #f5f6f7}
color.bg/default → {light: #ffffff, dark: #0b0c0f}
color.brand/primary → {light: #2563eb, dark: #60a5fa}

Exporting Tokens to Code (JSON → CSS Vars → Tailwind)

Export tokens as JSON. A minimal example:

{
  "color": {
    "bg": { "default": { "light": "#ffffff", "dark": "#0b0c0f" } },
    "fg": { "default": { "light": "#0b0c0f", "dark": "#f5f6f7" } },
    "brand": { "primary": { "light": "#2563eb", "dark": "#60a5fa" } }
  },
  "space": { "0": 0, "1": 4, "2": 8, "3": 12, "4": 16, "5": 20 },
  "radius": { "none": 0, "sm": 4, "md": 8, "lg": 12, "full": 9999 },
  "motion": { "duration": { "fast": 150, "base": 250, "slow": 400 } }
}

Transform this JSON into CSS variables at build time:

// scripts/build-tokens.ts
import fs from "node:fs";
const tokens = JSON.parse(fs.readFileSync("tokens.json", "utf8"));
 
function toCSSVars(obj: any, path: string[] = []) {
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(obj)) {
    const next = [...path, k];
    if (typeof v === "object" && v !== null && !("light" in v && "dark" in v)) {
      Object.assign(out, toCSSVars(v as any, next));
    } else {
      // leaf (or theme object)
      const name = `--${next.join("-")}`; // e.g., --color-bg-default
      if (typeof v === "object" && v.light && v.dark) {
        out[`${name}-light`] = String(v.light);
        out[`${name}-dark`] = String(v.dark);
      } else {
        out[name] = String(v);
      }
    }
  }
  return out;
}
 
const vars = toCSSVars(tokens);
const lines = [":root{"];
for (const [k, v] of Object.entries(vars)) {
  if (k.endsWith("-light")) lines.push(`${k.replace("-light", "")}: ${v};`);
  else if (!k.endsWith("-dark")) lines.push(`${k}: ${v};`);
}
lines.push("}");
lines.push(`[data-theme="dark"]{`);
for (const [k, v] of Object.entries(vars)) {
  if (k.endsWith("-dark")) lines.push(`${k.replace("-dark", "")}: ${v};`);
}
lines.push("}");
 
fs.writeFileSync("src/styles/tokens.css", lines.join(""));
console.log("Generated src/styles/tokens.css");

Add an npm/pnpm script:

{
  "scripts": {
    "tokens": "ts-node scripts/build-tokens.ts"
  }
}

You can also use Style Dictionary, Theo, or community token pipelines—same idea, less custom code.


Tailwind Configuration for Tokens & Themes

Map CSS variables to Tailwind theme values so utilities pull from tokens.

// tailwind.config.ts
import type { Config } from "tailwindcss";
 
export default {
  darkMode: ["class", '[data-theme="dark"]'],
  content: ["./src/**/*.{ts,tsx,mdx}"],
  theme: {
    extend: {
      colors: {
        bg: {
          DEFAULT: "var(--color-bg-default)",
          raised: "var(--color-bg-raised)",
        },
        fg: {
          DEFAULT: "var(--color-fg-default)",
          muted: "var(--color-fg-muted)",
        },
        brand: {
          DEFAULT: "var(--color-brand-primary)",
        },
      },
      borderRadius: {
        sm: "var(--radius-sm)",
        md: "var(--radius-md)",
        lg: "var(--radius-lg)",
        full: "var(--radius-full)",
      },
      spacing: {
        0: "0px",
        1: "calc(var(--space-1) * 1px)",
        2: "calc(var(--space-2) * 1px)",
        3: "calc(var(--space-3) * 1px)",
        4: "calc(var(--space-4) * 1px)",
        5: "calc(var(--space-5) * 1px)",
      },
      transitionDuration: {
        fast: "var(--motion-duration-fast)",
        base: "var(--motion-duration-base)",
        slow: "var(--motion-duration-slow)",
      },
    },
  },
  plugins: [],
} satisfies Config;

Import the generated CSS once (e.g., src/app/globals.css in Next.js):

@import "./styles/tokens.css";
@tailwind base;
@tailwind components;
@tailwind utilities;

Building Accessible React Primitives (Button, Input, Dialog)

Use headless primitives (e.g., Radix) for behavior and compose with Tailwind classes. For variants, a small utility like class-variance-authority (cva) is ergonomic.

pnpm add @radix-ui/react-dialog class-variance-authority

Button.tsx

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
 
const buttonStyles = cva(
  "inline-flex items-center justify-center rounded-md font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 disabled:opacity-50 disabled:pointer-events-none transition-colors",
  {
    variants: {
      variant: {
        solid: "bg-brand text-white hover:bg-brand/90",
        outline: "border border-fg/20 text-fg hover:bg-bg/80",
        ghost: "text-fg hover:bg-fg/5",
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-4 text-sm",
        lg: "h-12 px-6 text-base",
      },
    },
    defaultVariants: { variant: "solid", size: "md" },
  },
);
 
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonStyles>;
 
export function Button({ className, variant, size, ...props }: Props) {
  return (
    <button className={buttonStyles({ variant, size, className })} {...props} />
  );
}

Dialog.tsx (Radix)

import * as Dialog from "@radix-ui/react-dialog";
 
export function Modal({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="rounded-md bg-brand text-white px-4 py-2">
          Open
        </button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out" />
        <Dialog.Content
          className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-bg p-6 shadow-xl focus:outline-none"
          aria-describedby={undefined}
        >
          <Dialog.Title className="text-lg font-semibold text-fg">
            {title}
          </Dialog.Title>
          <div className="mt-4">{children}</div>
          <Dialog.Close asChild>
            <button className="mt-6 h-10 rounded-md border border-fg/20 px-4">
              Close
            </button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

A11y notes: Provide clear focus styles, ensure trigger & content are reachable via keyboard, and verify aria-labels/titles. Respect prefers-reduced-motion for transition utilities.


Theming & Dark Mode (CSS Variables + Data Attributes)

Use a single source of truth—CSS variables—with theme switching via data-theme or a class on <html>/<body>.

// ThemeToggle.tsx
"use client";
import * as React from "react";
 
export function ThemeToggle() {
  const [dark, setDark] = React.useState(false);
  React.useEffect(() => {
    const stored = localStorage.getItem("theme");
    if (stored) document.documentElement.setAttribute("data-theme", stored);
  }, []);
 
  function toggle() {
    const next =
      document.documentElement.getAttribute("data-theme") === "dark"
        ? "light"
        : "dark";
    document.documentElement.setAttribute("data-theme", next);
    localStorage.setItem("theme", next);
    setDark(next === "dark");
  }
 
  return (
    <button
      onClick={toggle}
      className="px-3 py-2 rounded-md border border-fg/20"
    >
      {dark ? "Switch to Light" : "Switch to Dark"}
    </button>
  );
}

Because Tailwind points to CSS variables in colors, all components update instantly when the attribute changes.


Documentation with Storybook (MDX) and Live Examples

  • Install & initialize Storybook: pnpm dlx storybook@latest init.
  • Author MDX stories showing usage, variants, and a11y notes.
  • Add design references in stories (screenshots, Figma links).
  • Use Controls to let consumers tweak props and see results.

Button.stories.mdx (excerpt)

import { Meta, Story, Canvas, ArgsTable } from "@storybook/blocks";
import { Button } from "./Button";
 
<Meta title="Primitives/Button" of={Button} />
 
# Button
 
Primary action button with `solid | outline | ghost` variants and `sm | md | lg` sizes.
 
<Canvas>
  <Story
    name="Playground"
    args={{ variant: "solid", size: "md", children: "Click me" }}
  />
</Canvas>
 
<ArgsTable of={Button} />

Testing (Unit, A11y, and Visual Regression)

  • Unit tests (Jest + React Testing Library): render variants, assert roles/labels.
  • A11y tests (axe-core / jest-axe): detect common violations.
  • Visual regression: run Storybook capture in CI to detect unintended UI diffs.
  • E2E (Playwright): verify keyboard navigation, dialogs, and forms.
// Button.test.tsx
import { render, screen } from "@testing-library/react";
import { Button } from "./Button";
test("renders a button with text", () => {
  render(<Button>Submit</Button>);
  expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
});

Packaging, Versioning, and CI/CD

  • Build your component library with tsup or Vite (ESM + types).
  • Semantic versioning with Changesets.
  • Automated releases: GitHub Actions to build, test, and publish.
  • Consumption: internal registry (GitHub Packages) or npm scope.
  • Changelog: auto-generated from changesets for clear upgrades.

Example GitHub Action (simplified)

name: ci
on: [push, pull_request]
jobs:
  build_test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: "pnpm" }
      - run: corepack enable
      - run: pnpm install --frozen-lockfile
      - run: pnpm build && pnpm test

Reference Folder Structure

design-system/
├─ tokens.json
├─ scripts/
│  └─ build-tokens.ts
├─ src/
│  ├─ styles/
│  │  ├─ tokens.css
│  │  └─ globals.css
│  ├─ components/
│  │  ├─ Button.tsx
│  │  ├─ Input.tsx
│  │  └─ Dialog.tsx
│  ├─ hooks/
│  ├─ utils/
│  └─ index.ts
├─ .storybook/
│  ├─ main.ts
│  └─ preview.ts
├─ package.json
└─ tailwind.config.ts

Keep primitives small and composable. Add patterns (e.g., forms, page shells) in a separate patterns/ folder.


FAQ: Design System Decisions

Q: Should I use headless primitives (Radix UI) or a full UI kit?
A: Headless primitives give you maximum control over design with first-class accessibility. Full kits are faster initially but harder to fully theme. For a custom brand, headless is often the best foundation.

Q: CSS variables or Tailwind theme objects?
A: Use CSS variables as the single source of truth. Point Tailwind to those vars so utilities automatically reflect theme changes (light/dark/brand).

Q: How do I handle motion for accessibility?
A: Respect prefers-reduced-motion and provide non-animated fallbacks. Avoid large parallax on content essential for comprehension.

Q: Do I need Figma tokens?
A: You need some token source. Whether it’s Figma Variables or a separate YAML/JSON, alignment between design and code is what matters.


Next steps

  • Wire your own Figma Variables, export tokens, and run the token build script.
  • Add 2–3 primitives, document them in Storybook, and set up tests.
  • Establish versioning + release so teams can depend on your design system with confidence.

Read more like this

How to Build a Modern Design System with React and Tailwind CSS: From Figma to Code | Ruixen UI