Web Development
Next.jsDesign SystemStorybookTailwind CSSRadix UIAccessibilityMonorepoTesting

Building a Design System in Next.js with Radix UI, Tailwind, and Storybook: End-to-End Guide for 2026

AO
Adrijan Omićević
·13 min read

# What You’ll Build and Why It Matters#

A design system in a Next.js product is not a Figma library. It is a versioned UI package with tokens, components, accessibility defaults, documentation, and tests that enforce consistency.

This guide shows an end-to-end approach for building a Next.js design system Storybook Tailwind Radix stack that scales: tokens and theming, Radix-based components, Storybook documentation, integration into a product codebase, and governance through linting and visual regression.

If you want deeper background on tokens and architecture, read these first:

# Architecture Overview: Monorepo, Packages, and Release Flow#

The fastest way to keep a design system usable is to make it easy to consume and hard to misuse. A monorepo with workspace packages typically hits that balance.

PathPurposeNotes
apps/webNext.js product appUses @acme/ui components only
packages/uiDesign system componentsRadix primitives, Tailwind styling, exported API
packages/tokensTokens and Tailwind presetCentral source of truth for colors, spacing, radii
packages/eslint-configConsistency rulesRestrict raw HTML, enforce import boundaries
packages/storybookStorybook hostCan live inside packages/ui or as separate app

This structure supports:

  • Independent versioning of @acme/ui
  • Shared tokens across apps
  • Centralized lint rules
  • Storybook as the single documentation site

Tooling choices that work well in 2026#

ConcernRecommendedWhy
WorkspacespnpmFast installs, strict dependency isolation
Build systemTurborepoCaching and parallel builds for CI
VersioningChangesetsPredictable releases and changelog generation
Visual regressionChromatic or Playwright screenshotsCatch UI drift before deploy
A11y automationStorybook a11y + testing-libraryFinds common WCAG issues early

🎯 Key Takeaway: Treat the design system as a product: version it, test it, document it, and enforce it in CI.

# Step 1: Design Tokens as the Source of Truth#

Tokens must be usable by both Tailwind utilities and component styles. The practical approach is to define tokens in CSS variables, then map Tailwind to those variables.

Token strategy: CSS variables plus Tailwind mapping#

Use semantic tokens like --color-bg, not raw values like --blue-500. Semantic tokens support theming and reduce refactors.

Example token file in packages/tokens/src/styles/tokens.css:

CSS
:root {
  --color-bg: 255 255 255;
  --color-fg: 17 24 39;
  --color-primary: 59 130 246;
 
  --radius-sm: 6px;
  --radius-md: 10px;
 
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
}
 
[data-theme="dark"] {
  --color-bg: 17 24 39;
  --color-fg: 243 244 246;
  --color-primary: 96 165 250;
}

Use RGB triplets so Tailwind can apply opacity via rgb(var(--token) / alpha).

Tailwind preset in the tokens package#

Create packages/tokens/src/tailwind-preset.ts:

TypeScript
import type { Config } from "tailwindcss";
 
export const preset: Config = {
  theme: {
    extend: {
      colors: {
        bg: "rgb(var(--color-bg) / <alpha-value>)",
        fg: "rgb(var(--color-fg) / <alpha-value>)",
        primary: "rgb(var(--color-primary) / <alpha-value>)",
      },
      borderRadius: {
        sm: "var(--radius-sm)",
        md: "var(--radius-md)",
      },
      spacing: {
        1: "var(--space-1)",
        2: "var(--space-2)",
        3: "var(--space-3)",
      },
    },
  },
};

Publish tokens.css and the Tailwind preset from @acme/tokens. Your product app and @acme/ui both depend on it.

💡 Tip: Keep the token surface area small. Most teams need fewer than 10 semantic color tokens per theme to move fast without constant token debates.

Loading tokens in Next.js#

In apps/web/src/app/layout.tsx, import the compiled CSS from tokens:

TSX
import "@acme/tokens/tokens.css";
import "./globals.css";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" data-theme="light">
      <body className="bg-bg text-fg">{children}</body>
    </html>
  );
}

Set data-theme based on user preference or system setting, but keep it deterministic for SSR.

# Step 2: Build Components on Radix UI Primitives#

Radix UI handles the hard parts: focus traps, keyboard navigation, ARIA attributes, and escape key behavior. Tailwind handles the visual layer. Your design system should wrap Radix in a consistent API.

Component conventions that scale#

Define a few rules and enforce them:

  • Export only from a single entrypoint, for example @acme/ui.
  • Each component folder includes index.ts, *.tsx, and *.stories.tsx.
  • Use a cn helper to merge Tailwind classes.
  • Avoid exposing Radix internals unless needed.

Example cn helper in packages/ui/src/lib/cn.ts:

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

Example component: Button#

A Button sets the baseline for all components: sizes, states, focus ring, and disabled behavior.

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

This gives you:

  • A consistent focus ring across the product
  • Predictable sizing tokens
  • Strict variants that prevent one-off styling

Example component: Dialog using Radix#

Dialog is where Radix pays off immediately. Focus management bugs are expensive, and they tend to ship unnoticed.

TSX
import * as Dialog from "@radix-ui/react-dialog";
import { cn } from "../lib/cn";
 
export function Modal({
  title,
  children,
  open,
  onOpenChange,
}: {
  title: string;
  children: React.ReactNode;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content
          className={cn(
            "fixed left-1/2 top-1/2 w-[90vw] max-w-lg -translate-x-1/2 -translate-y-1/2",
            "rounded-md bg-bg p-6 shadow-lg focus-visible:outline-none"
          )}
        >
          <Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
          <div className="mt-3">{children}</div>
          <Dialog.Close className="mt-6 inline-flex h-10 px-4 items-center rounded-md bg-fg/10">
            Close
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

⚠️ Warning: Do not remove Radix default behaviors like focus trapping or escape-to-close to match a “pixel-perfect” prototype. Those behaviors are accessibility requirements, and removing them increases support costs and legal risk.

# Step 3: Accessibility Defaults That Don’t Rely on Developer Discipline#

Most accessibility regressions come from inconsistent defaults: missing labels, broken focus styles, and incorrect semantics. Your design system should reduce decisions developers need to make.

A11y checklist baked into components#

Component areaDefaultWhy it matters
Focus stylesfocus-visible:ring-2Keyboard users need a clear focus indicator
LabelsRequire aria-label when no visible labelScreen readers need accessible names
Disabled statedisabled:pointer-events-none plus opacityPrevents accidental clicks and clarifies state
Hit targetsMinimum height h-10 for buttonsImproves usability on mobile
Dialogs and menusRadix primitivesCorrect roving tabindex and focus management

Practical rule: “No icon-only buttons without a label”#

Make this easy by offering an IconButton component that requires a label prop.

TSX
import * as React from "react";
import { Button } from "./button";
 
export function IconButton(
  props: Omit<React.ComponentProps<typeof Button>, "children"> & {
    label: string;
    icon: React.ReactNode;
  }
) {
  const { label, icon, ...rest } = props;
  return (
    <Button aria-label={label} {...rest}>
      {icon}
    </Button>
  );
}

This prevents one of the most common accessibility failures in production UIs.

# Step 4: Storybook as Documentation, Playground, and Test Runner#

Storybook is where a design system becomes usable across teams. It reduces Slack questions, makes review faster, and acts as the UI test harness.

Storybook setup essentials#

Install dependencies in the Storybook host package, then ensure it imports tokens.

In .storybook/preview.ts:

TypeScript
import "@acme/tokens/tokens.css";
import type { Preview } from "@storybook/react";
 
const preview: Preview = {
  parameters: {
    controls: { expanded: true },
    a11y: { test: "todo" },
  },
};
 
export default preview;

A good story is a usage example, not a snapshot#

A story should:

  • Show variants and sizes
  • Provide controls for key props
  • Demonstrate “real” content, not lorem ipsum only

Example button.stories.tsx:

TSX
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
 
const meta: Meta<typeof Button> = {
  title: "Core/Button",
  component: Button,
  args: { children: "Continue", variant: "primary", size: "md" },
};
export default meta;
 
export const Primary: StoryObj<typeof Button> = {};
export const Secondary: StoryObj<typeof Button> = {
  args: { variant: "secondary" },
};
export const Disabled: StoryObj<typeof Button> = {
  args: { disabled: true },
};

ℹ️ Note: For many teams, Storybook replaces a separate design system website. The time saved in onboarding and UI review often exceeds the initial setup cost within the first 4 to 8 weeks.

Storybook interaction tests#

Interaction tests catch behavior issues like “Escape closes modal” and “Tab stays within dialog.” These issues are expensive to catch via manual QA only.

Keep the test small and focused:

TSX
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
import { Modal } from "./modal";
 
const meta: Meta<typeof Modal> = { title: "Overlays/Modal", component: Modal };
export default meta;
 
export const OpensAndCloses: StoryObj<typeof Modal> = {
  render: () => <Modal title="Title" open={true} onOpenChange={() => {}}>Body</Modal>,
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await expect(canvas.getByText("Title")).toBeInTheDocument();
  },
};

# Step 5: Integrate the Design System into a Next.js Product Codebase#

You have two integration goals:

  1. 1
    Make adoption easy
  2. 2
    Make bypassing difficult

Integration checklist#

AreaWhat to doWhat it prevents
ImportsUse @acme/ui onlyRandom component copies
TokensLoad @acme/tokens/tokens.css in root layoutColor drift across apps
TailwindUse tokens preset in app configHardcoded values in utilities
SSRKeep theme deterministic on serverHydration mismatch
MigrationReplace UI incrementallyBig bang rewrites that stall

In apps/web/tailwind.config.ts, consume the preset:

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

Migration pattern that works in real teams#

Replace high-churn UI first:

  • Buttons and links
  • Form controls
  • Modals and dropdowns
  • Tables and data display

This reduces bug count faster because these components appear on many screens and have many states.

# Step 6: Enforce Consistency with Linting and Import Boundaries#

Consistency does not scale with code review alone. Automated enforcement is cheaper and more reliable.

ESLint rules to discourage bypassing#

Use no-restricted-imports to block direct Radix imports in product code, forcing @acme/ui wrappers:

JavaScript
module.exports = {
  rules: {
    "no-restricted-imports": [
      "error",
      {
        paths: [
          {
            name: "@radix-ui/react-dialog",
            message: "Use Modal from @acme/ui instead of importing Radix directly.",
          },
        ],
      },
    ],
  },
};

Add restrictions for raw HTML elements when appropriate. For example, disallow raw button in app code to ensure focus styles and variants are consistent. Apply this carefully to avoid slowing down legitimate cases.

💡 Tip: Make the rule opt-in per folder. Enforce it strictly in apps/web/src/features first, and allow exceptions in apps/web/src/playground or migration folders.

Tailwind class consistency rules#

If you allow arbitrary Tailwind usage, you will get inconsistent UI. The goal is not to ban Tailwind, but to limit it to approved tokens and patterns.

Common enforcement options:

  • Restrict specific classes like text-red-500 in app code, requiring semantic tokens instead.
  • Require bg-bg, text-fg, and semantic colors rather than palette colors.

This is where the Tailwind best practices approach pays off: fewer custom utility overrides and fewer one-off class strings.

# Step 7: Visual Regression Testing and CI Gates#

Unit tests miss UI issues like spacing, colors, and z-index layering. Visual regression tests catch those reliably.

Two practical options#

OptionSetup effortOngoing costBest for
ChromaticLowPaid at scaleTeams that want speed and hosted diffs
Playwright screenshotsMediumCompute time in CITeams that want full control and self-hosting

A realistic baseline is to snapshot:

  • Button variants
  • Form fields states
  • Modal and popover layering
  • Dark mode themes

Minimal Playwright screenshot example#

Create a story route and snapshot the rendered story iframe. Keep it simple and small per test.

TypeScript
import { test, expect } from "@playwright/test";
 
test("Button primary visual", async ({ page }) => {
  await page.goto("http://localhost:6006/?path=/story/core-button--primary");
  await expect(page).toHaveScreenshot("button-primary.png", { fullPage: true });
});

This catches changes in padding, border radius, and color tokens immediately.

⚠️ Warning: Visual regression only works if environments are deterministic. Pin fonts, set a consistent viewport, and avoid time-based UI in stories.

# Step 8: Versioning, Releases, and Backwards Compatibility#

A design system that cannot ship safely will be ignored. Versioning and changelogs are what make adoption predictable.

Use semantic versioning with Changesets#

A practical policy:

  • Patch releases: bug fixes, internal refactors
  • Minor releases: new components, new props with defaults
  • Major releases: breaking API, token rename, behavior change

Release checklist per change:

  • Updated stories
  • Updated docs in Storybook
  • Visual snapshots updated and approved
  • Changeset file added

Example changeset file:

Md
---
"@acme/ui": minor
---
 
Add secondary variant to Button and document usage in Storybook.

Deprecation strategy for real products#

Breaking changes create churn. Prefer deprecations with a timeline:

  • Keep old prop working for 1 to 2 minor versions
  • Add a lint rule warning for deprecated usage
  • Provide codemods for mechanical migrations

If the design system is used across multiple apps, this approach can save days per quarter.

# Key Takeaways#

  • Define tokens as semantic CSS variables and map Tailwind to them so themes and utilities stay consistent.
  • Wrap Radix UI primitives with your own API to standardize accessibility, focus styles, and variants.
  • Use Storybook as documentation plus a test harness with a11y checks and interaction tests, not just a gallery.
  • Integrate via workspace packages and Tailwind presets, then migrate high-churn UI first to get fast ROI.
  • Enforce usage with lint rules and import boundaries, and catch UI drift with visual regression in CI.
  • Version the design system with Changesets and semantic versioning, using deprecations to avoid breaking product teams.

# Conclusion#

A production-grade design system in Next.js is a pipeline: tokens drive Tailwind, Radix provides accessible behavior, Storybook documents and tests, and CI enforces consistency through linting and visual regression.

If you want help designing the token model, building Radix-based components, or setting up Storybook plus CI gates for a Next.js product, contact Samioda and we can implement a versioned design system that your team can ship confidently.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

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