Web Development
ReactDesign SystemTailwind CSSRadix UITypeScriptDesign TokensMonorepo

Building a React Design System with Design Tokens: Tailwind CSS + Radix UI + TypeScript

AO
Adrijan Omićević
·14 min read

# What You’ll Build#

You’ll build a React design system with Tailwind and Radix that is reusable across multiple apps, themeable, accessible by default, and versioned like a real product.

You’ll implement:

  • Design tokens for colors, spacing, and typography as CSS variables.
  • A theming strategy that works across React and Next.js apps.
  • Accessible primitives using Radix UI, wrapped into opinionated components.
  • Packaging and distribution for reuse, plus conventions for versioning and documentation.

If you want deeper architecture patterns, pair this guide with React Component Architecture for a Scalable Design System. For Tailwind conventions used below, see Tailwind CSS Best Practices. If you are integrating this into a product app, Getting Started with Next.js covers the baseline setup.

# Prerequisites#

RequirementVersionNotes
Node.js18 or 20LTS recommended for monorepos
React18+Radix and modern tooling assume React 18
TypeScript5+For satisfies, better type inference
Tailwind CSS3.4+Variable-driven theming works well
Radix UIlatestUse primitives per component package
Package managerpnpm recommendedFaster workspaces and lockfile stability

A design system is a product. Treat it like one: separate packages, strict APIs, automated releases, and a predictable build.

A practical layout for multiple apps:

PathPurposePublished
apps/webNext.js or React app consuming the design systemNo
apps/adminAnother app consuming the same UI packageNo
packages/tokensDesign token source and generated CSS variablesYes
packages/uiReact components built on Radix + TailwindYes
packages/configShared tooling, Tailwind preset, ESLint rulesOptional

Why split tokens from UI#

Tokens are the foundation and should be framework-agnostic. You might consume them in web, email templates, docs, or even mobile later. Keeping tokens in their own package reduces coupling and makes them easier to audit.

# Step 1: Define Design Tokens as CSS Variables#

A token is not “blue 500”. A token is “primary” and it can map to different values per theme. The key is separating meaning from value.

A simple token model:

  • Semantic tokens: --color-bg, --color-fg, --color-primary, --color-danger
  • Component-level tokens only when needed: --button-bg, --card-radius
  • Scale tokens: spacing steps, font sizes, radii, shadows

Colors tokens: semantic first#

Create packages/tokens/src/tokens.css:

CSS
:root {
  /* Base */
  --color-bg: 0 0% 100%;
  --color-fg: 222 47% 11%;
 
  /* Brand */
  --color-primary: 222 89% 55%;
  --color-primary-fg: 0 0% 100%;
 
  /* Surfaces */
  --color-muted: 210 40% 96%;
  --color-muted-fg: 215 16% 47%;
 
  /* Feedback */
  --color-danger: 0 84% 60%;
  --color-danger-fg: 0 0% 100%;
 
  /* Borders and focus */
  --color-border: 214 32% 91%;
  --color-ring: 222 89% 55%;
 
  /* Radii */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 14px;
 
  /* Spacing scale */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
 
  /* Typography */
  --font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
  --text-xs: 12px;
  --text-sm: 14px;
  --text-md: 16px;
  --text-lg: 18px;
  --text-xl: 20px;
  --leading-tight: 1.2;
  --leading-normal: 1.5;
}
 
:root[data-theme="dark"] {
  --color-bg: 222 47% 11%;
  --color-fg: 210 40% 98%;
 
  --color-muted: 217 33% 17%;
  --color-muted-fg: 215 20% 65%;
 
  --color-border: 217 33% 17%;
  --color-ring: 222 89% 65%;
}

This uses HSL components so Tailwind can apply alpha via hsl(var(--token) / 0.8) cleanly.

💡 Tip: Keep tokens small and semantic. Teams that start with 200 plus color tokens usually end up with inconsistent naming and duplicated usage. Start with 20 to 40 semantic tokens and add only when a real UI need appears.

Spacing tokens: scale beats arbitrary numbers#

You will build faster if spacing is predictable. A common design system failure is “just use any padding that looks good”.

Use a scale you can memorize:

  • --space-1 through --space-8
  • derive component paddings from the scale

Typography tokens: encode intent, not only sizes#

Tokens like --text-md are fine, but a more scalable convention is role-based tokens like --text-body, --text-caption, --text-heading. If your product has only one typographic style, start with sizes and expand later.

# Step 2: Map Tokens Into Tailwind (Without Duplicating Values)#

Tailwind should reference tokens. It should not re-own token values. This reduces divergence and makes theme switching automatic.

In packages/config/tailwind-preset.ts:

TypeScript
import type { Config } from "tailwindcss";
 
export const preset = {
  theme: {
    extend: {
      colors: {
        bg: "hsl(var(--color-bg) / <alpha-value>)",
        fg: "hsl(var(--color-fg) / <alpha-value>)",
        primary: "hsl(var(--color-primary) / <alpha-value>)",
        "primary-fg": "hsl(var(--color-primary-fg) / <alpha-value>)",
        muted: "hsl(var(--color-muted) / <alpha-value>)",
        "muted-fg": "hsl(var(--color-muted-fg) / <alpha-value>)",
        border: "hsl(var(--color-border) / <alpha-value>)",
        ring: "hsl(var(--color-ring) / <alpha-value>)",
        danger: "hsl(var(--color-danger) / <alpha-value>)",
        "danger-fg": "hsl(var(--color-danger-fg) / <alpha-value>)",
      },
      borderRadius: {
        sm: "var(--radius-sm)",
        md: "var(--radius-md)",
        lg: "var(--radius-lg)",
      },
      spacing: {
        1: "var(--space-1)",
        2: "var(--space-2)",
        3: "var(--space-3)",
        4: "var(--space-4)",
        6: "var(--space-6)",
        8: "var(--space-8)",
      },
      fontFamily: {
        sans: "var(--font-sans)",
      },
      fontSize: {
        xs: ["var(--text-xs)", { lineHeight: "var(--leading-normal)" }],
        sm: ["var(--text-sm)", { lineHeight: "var(--leading-normal)" }],
        md: ["var(--text-md)", { lineHeight: "var(--leading-normal)" }],
        lg: ["var(--text-lg)", { lineHeight: "var(--leading-normal)" }],
        xl: ["var(--text-xl)", { lineHeight: "var(--leading-tight)" }],
      },
    },
  },
} satisfies Config;

This enables usage like:

  • bg-bg text-fg
  • bg-primary text-primary-fg
  • border-border ring-ring
  • p-4 gap-2 rounded-md

Make the preset consumable in apps#

In each app Tailwind config, include the preset and scan UI package files. In apps/web/tailwind.config.ts:

TypeScript
import type { Config } from "tailwindcss";
import { preset } from "@acme/config/tailwind-preset";
 
export default {
  presets: [preset],
  content: [
    "./src/**/*.{ts,tsx}",
    "../../packages/ui/src/**/*.{ts,tsx}",
  ],
} satisfies Config;

⚠️ Warning: If you forget to include your UI package paths in Tailwind content, production builds will tree-shake away your component classes. This is one of the most common causes of “it works locally but not in CI”.

# Step 3: Add Theming That Works Across Apps#

Token-driven theming is basically toggling a theme selector and letting CSS variables do the work.

Theme switch strategy#

Use a data attribute on the document root:

  • Light theme: data-theme="light" or no attribute
  • Dark theme: data-theme="dark"

In Next.js, apply it at the html element level to avoid flashes. The exact mechanism depends on your stack, but the design system should only require the attribute, not a specific theme library.

A minimal client-side toggle in a React app:

TypeScript
export function setTheme(theme: "light" | "dark") {
  document.documentElement.setAttribute("data-theme", theme);
  localStorage.setItem("theme", theme);
}

Why this matters for design systems#

When tokens are variables and Tailwind points to variables, the same component code renders:

  • Dark theme without a second class set
  • Brand theme by swapping a handful of token values
  • Per-tenant theming in multi-tenant apps

That is how you avoid duplicating component variants and bloating the API.

# Step 4: Build Accessible Primitives with Radix UI#

Radix UI gives you accessible behavior, focus management, keyboard interactions, and ARIA attributes. Your job is to add styling, variants, and consistent APIs.

A good rule: use Radix for behavior and semantics, Tailwind for appearance, and TypeScript for constraints.

Component conventions you should standardize#

These conventions prevent API drift across components:

ConventionRecommended standardWhy it matters
StylingclassName merged via a utilityConsistent escape hatch
Variantsvariant and size propsPredictable usage across UI
CompositionasChild support when usefulAllows rendering as a, button, etc.
Ref forwardingReact.forwardRef for interactive elementsRequired for Radix and forms
Data attributesUse data-state and data-disabledStyle Radix state without JS

Utility: className merge#

In packages/ui/src/utils/cn.ts:

TypeScript
export function cn(...classes: Array<string | undefined | false>) {
  return classes.filter(Boolean).join(" ");
}

Keep it simple. If you need conflict resolution for Tailwind, introduce a merge utility later, but avoid complexity early.

Example: Button component as a base building block#

This is intentionally short and production-oriented.

TSX
import * as React from "react";
import { cn } from "../utils/cn";
 
type ButtonVariant = "primary" | "secondary" | "danger";
type ButtonSize = "sm" | "md";
 
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: ButtonVariant;
  size?: ButtonSize;
};
 
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "primary", size = "md", ...props }, ref) => {
    const base =
      "inline-flex items-center justify-center rounded-md font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none";
 
    const variants: Record<ButtonVariant, string> = {
      primary: "bg-primary text-primary-fg hover:opacity-90",
      secondary: "bg-muted text-fg hover:opacity-90",
      danger: "bg-danger text-danger-fg hover:opacity-90",
    };
 
    const sizes: Record<ButtonSize, string> = {
      sm: "h-9 px-3 text-sm",
      md: "h-10 px-4 text-md",
    };
 
    return (
      <button
        ref={ref}
        className={cn(base, variants[variant], sizes[size], className)}
        {...props}
      />
    );
  }
);
 
Button.displayName = "Button";

This uses tokens for colors and ring. When the theme changes, the button changes without a new set of classes.

Example: Dialog using Radix primitives#

Dialog is a perfect Radix example because accessibility is non-trivial to implement correctly.

TSX
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "../utils/cn";
 
export function Modal({
  open,
  onOpenChange,
  title,
  children,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  children: React.ReactNode;
}) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-fg/30" />
        <Dialog.Content
          className={cn(
            "fixed left-1/2 top-1/2 w-[min(92vw,520px)] -translate-x-1/2 -translate-y-1/2",
            "rounded-lg border border-border bg-bg p-4 text-fg shadow"
          )}
        >
          <Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
          <div className="mt-3">{children}</div>
          <Dialog.Close className="mt-4">
            Close
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

The accessibility value here is tangible:

  • Escape closes the dialog.
  • Focus is trapped.
  • Background is inert for screen readers.
  • ARIA roles and labels are correct.

These details are why Radix is a strong choice for a design system foundation.

ℹ️ Note: Radix components expose state via data attributes like data-state="open". Use these in Tailwind classes when you want animations or state-based styling without adding React state.

# Step 5: Token-First Styling Patterns That Scale#

As your UI grows, the highest ROI convention is “token-first utilities”. Instead of writing component-specific colors, you compose bg-bg text-fg border-border and add variants only where meaningful.

Use semantic utilities in components#

  • Layout: p-4, gap-2, rounded-md
  • Surface: bg-bg, border-border, text-fg
  • States: focus-visible:ring-ring, disabled:opacity-50
  • Feedback: text-danger, bg-danger

This avoids hard-coded “brand blue” leaking into components. It also makes re-branding possible without refactoring.

When to add component-level tokens#

Add component-level tokens only if:

  • The component has a unique styling requirement not shared elsewhere.
  • Multiple products need different component styles while keeping the same semantic theme.

Example: if your Card needs a special shadow per product, introduce --card-shadow rather than baking a Tailwind shadow class into the component.

# Step 6: Package the Design System for Reuse Across Apps#

The difference between a component folder and a design system is distribution and discipline. Packaging forces you to define your public API.

What to export#

Expose a clean surface area:

PackageExportExample
@acme/tokensCSS variables and token docsdist/tokens.css
@acme/uiComponents and typesButton, Modal, Input
@acme/configTailwind presetpreset

Build strategy#

Keep builds boring:

  • @acme/tokens publishes CSS.
  • @acme/ui compiles TypeScript and ships CSS through Tailwind usage in consumer apps.

If you want the UI package to ship prebuilt CSS, do it only when you have a clear integration need. Shipping CSS can be helpful for non-Tailwind consumers but adds complexity in bundling and deduping.

Ensure consumers load tokens#

Add a single import in app global CSS, for example in Next.js app/globals.css:

CSS
@import "@acme/tokens/tokens.css";
 
@tailwind base;
@tailwind components;
@tailwind utilities;
 
body {
  background: hsl(var(--color-bg));
  color: hsl(var(--color-fg));
}

# Step 7: Versioning Conventions That Prevent Upgrade Chaos#

If multiple apps depend on your UI, versioning becomes operational. Semantic versioning is necessary but not sufficient. You also need rules for what counts as a breaking change.

ChangeVersion bumpExample
Breaking API changeMajorRename prop, change default behavior
Backwards-compatible featureMinorAdd a new component or non-breaking variant
Bug fix onlyPatchFix focus ring, class bug, typing bug

Practical breaking changes many teams miss:

  • Changing default variant or size values.
  • Changing token meaning even if the name is unchanged.
  • Changing DOM structure that tests rely on.

Automate with Changesets#

Changesets is a good default for monorepos because it forces human-readable release notes and ties them to PRs.

A typical workflow:

  1. 1
    Add a changeset file per PR that affects published packages.
  2. 2
    Merge to main.
  3. 3
    CI publishes packages and generates changelog.

Your minimum bar for every release:

  • Summary
  • Migration notes for breaking changes
  • Affected packages

🎯 Key Takeaway: The fastest way to lose trust in a design system is shipping silent breaking changes. Strict versioning and changelogs are not bureaucracy, they are product reliability.

# Step 8: Documentation That Developers Actually Use#

Documentation should answer “how do I use this safely in production” in under two minutes per component.

Minimum documentation per component#

SectionWhat to includeExample
PurposeWhen to use, when not toButton vs LinkButton guidance
APIProps, defaults, variantsvariant, size, disabled behavior
AccessibilityKeyboard and screen reader notesFocus order, labels, aria-* expectations
ExamplesCopy-paste snippetsPrimary, secondary, destructive usage
TokensWhich tokens it depends on--color-primary, --color-ring

Practical doc format#

Keep docs in the repo near the source. A common pattern:

  • packages/ui/src/button/button.mdx
  • packages/ui/src/modal/modal.mdx

Even if you later move to a full docs site, starting close to code keeps documentation updated because it changes with the same PR.

Include usage examples tied to Next.js#

Because many teams build with Next.js, include at least one example per component that works with the Next.js App Router patterns. If you need baseline project structure, reference Getting Started with Next.js.

# Step 9: Production Checks: Accessibility, Consistency, and Adoption#

A design system succeeds when adoption is easy and regressions are rare.

Accessibility checks you can standardize#

  • Keyboard-only navigation for all interactive components.
  • Visible focus state on all focusable elements.
  • Modal and popover focus trapping and escape handling.
  • Form controls must have labels, descriptions, and error messaging patterns.

A practical KPI to track: reduce design-related bugs. Many teams report that standardizing on an accessible component library reduces UI regressions significantly because behavior is no longer re-implemented per feature.

Consistency checks#

  • Token usage only for colors and spacing in shared components.
  • No hard-coded hex colors in @acme/ui.
  • Variants and sizes aligned across components.
  • Deprecation path for old components, not sudden removals.

For component boundaries and reuse rules across teams, align with the patterns in React Component Architecture for a Scalable Design System.

Tailwind maintainability rules#

If Tailwind utility strings become hard to read:

  • Extract to small constants inside the component.
  • Avoid “mega-class strings” that mix layout, state, and theme in one line.
  • Keep variants explicit.

For a complete checklist, see Tailwind CSS Best Practices.

# Key Takeaways#

  • Define design tokens as semantic CSS variables and make Tailwind reference them so theming becomes a token swap, not a component rewrite.
  • Use Radix UI for accessible behavior and wrap primitives into opinionated React components with consistent variant, size, and className patterns.
  • Package tokens, Tailwind preset, and UI components separately so multiple apps can upgrade independently and safely.
  • Enforce semantic versioning with automated changelogs to prevent silent breaking changes across consuming apps.
  • Document each component with purpose, API, accessibility notes, token dependencies, and copy-paste examples to improve adoption.

# Conclusion#

A React design system with Tailwind and Radix becomes maintainable when tokens are the single source of truth, Radix provides accessible primitives, and your UI package ships a consistent API with disciplined versioning.

If you want Samioda to help you build or modernize a production design system, including token architecture, Radix-based components, and automated releases across multiple apps, reach out through our website and we’ll scope a practical rollout plan.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.