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
- Prerequisites & Tooling
- Design Tokens in Figma (Naming & Variables)
- Exporting Tokens to Code (JSON → CSS Vars → Tailwind)
- Tailwind Configuration for Tokens & Themes
- Building Accessible React Primitives (Button, Input, Dialog)
- Theming & Dark Mode (CSS Variables + Data Attributes)
- Documentation with Storybook (MDX) and Live Examples
- Testing (Unit, A11y, and Visual Regression)
- Packaging, Versioning, and CI/CD
- Reference Folder Structure
- 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 to0, 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-authorityButton.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-motionfor 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 testReference 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.

