Get in Touch

Need a website, app, or MVP? Let's talk.

info@gexpsoftware.com →

Puerto Jiménez, Costa Rica

info@gexpsoftware.com

© 2026 Marcelo Retana

All guides

How to Build a Design System with Tailwind CSS | Full Stack Guide

~4-6 hoursintermediate7 steps

A design system is more than a component library — it's the single source of truth for colors, spacing, typography, and interaction patterns across your product. Tailwind CSS v4 makes this easier with native CSS variables, the @theme directive, and zero-config content detection. This guide walks you through building a token-based design system that scales from a solo project to a team.

Share:XLinkedIn

Prerequisites

  • -
    Node.js 20+

    Required for Tailwind CSS v4 and the Vite-based build toolchain.

  • -
    Tailwind CSS v4

    This guide uses Tailwind CSS v4 which introduces @theme, CSS-first configuration, and native CSS variable support.

  • -
    Basic CSS Knowledge

    Understanding of CSS custom properties, media queries, and the cascade.

  • -
    React (Optional)

    The component examples use React, but the token and utility layer works with any framework.

01

Set Up the Project with Tailwind CSS v4

Create a Vite project and install Tailwind CSS v4. In v4, configuration is done in CSS using the @theme directive instead of a JavaScript config file. The PostCSS plugin detects your content files automatically. Set up a clean project structure for your design system source files.

bash
npm create vite@latest design-system -- --template react-ts
cd design-system

npm install tailwindcss @tailwindcss/vite

# Project structure
mkdir -p src/tokens src/components src/layouts src/utils

Tip: Tailwind CSS v4 uses @import 'tailwindcss' in your CSS entry point — no @tailwind directives needed.

Tip: The Vite plugin handles content detection automatically — no content array configuration required.

02

Define Design Tokens with @theme and CSS Variables

Create your foundational design tokens — colors, spacing, typography, and shadows — using the @theme directive. Tailwind v4 turns these into CSS custom properties that you can reference anywhere. Structure tokens in semantic layers: primitive values, then semantic aliases that map to specific UI purposes.

src/tokens/theme.csscss
/* src/tokens/theme.css */
@import 'tailwindcss';

@theme {
  /* Primitive color scale */
  --color-gray-50: oklch(0.985 0 0);
  --color-gray-100: oklch(0.967 0.001 286.375);
  --color-gray-200: oklch(0.928 0.006 264.531);
  --color-gray-300: oklch(0.872 0.01 258.338);
  --color-gray-500: oklch(0.551 0.027 264.364);
  --color-gray-700: oklch(0.373 0.034 264.364);
  --color-gray-900: oklch(0.21 0.034 264.364);

  --color-brand-50: oklch(0.97 0.014 254.604);
  --color-brand-100: oklch(0.932 0.032 255.585);
  --color-brand-500: oklch(0.623 0.214 259.815);
  --color-brand-600: oklch(0.546 0.245 262.881);
  --color-brand-700: oklch(0.488 0.243 264.376);

  /* Semantic tokens */
  --color-surface: var(--color-gray-50);
  --color-surface-raised: white;
  --color-surface-overlay: white;
  --color-text-primary: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-500);
  --color-text-on-brand: white;
  --color-border: var(--color-gray-200);
  --color-border-strong: var(--color-gray-300);
  --color-accent: var(--color-brand-500);
  --color-accent-hover: var(--color-brand-600);

  /* Spacing scale */
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;
  --spacing-2xl: 3rem;
  --spacing-3xl: 4rem;

  /* Typography */
  --font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;

  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: 1.125rem;
  --text-xl: 1.25rem;
  --text-2xl: 1.5rem;
  --text-3xl: 1.875rem;

  /* Shadows */
  --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px oklch(0 0 0 / 0.07), 0 2px 4px oklch(0 0 0 / 0.05);
  --shadow-lg: 0 10px 15px oklch(0 0 0 / 0.1), 0 4px 6px oklch(0 0 0 / 0.05);

  /* Radii */
  --radius-sm: 0.375rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --radius-full: 9999px;
}

Tip: Use OKLCH for colors — it's perceptually uniform, so lightness values look consistent across hues.

Tip: Semantic tokens (--color-accent, --color-surface) decouple components from raw color values, making theming trivial.

03

Add Dark Mode with Token Overrides

Implement dark mode by overriding your semantic tokens inside a CSS media query or class-based selector. Because your components reference semantic tokens like --color-surface and --color-text-primary, switching themes only requires changing the variable values — no component code changes at all.

src/tokens/dark-mode.csscss
/* src/tokens/dark-mode.css */
@layer base {
  @media (prefers-color-scheme: dark) {
    :root {
      --color-surface: oklch(0.145 0.017 264.364);
      --color-surface-raised: oklch(0.197 0.021 264.364);
      --color-surface-overlay: oklch(0.237 0.024 264.364);
      --color-text-primary: oklch(0.967 0.001 286.375);
      --color-text-secondary: oklch(0.551 0.027 264.364);
      --color-border: oklch(0.295 0.03 264.364);
      --color-border-strong: oklch(0.373 0.034 264.364);
      --color-accent: oklch(0.707 0.165 254.624);
      --color-accent-hover: oklch(0.623 0.214 259.815);

      --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.3);
      --shadow-md: 0 4px 6px oklch(0 0 0 / 0.4), 0 2px 4px oklch(0 0 0 / 0.2);
      --shadow-lg: 0 10px 15px oklch(0 0 0 / 0.5), 0 4px 6px oklch(0 0 0 / 0.3);
    }
  }

  /* Class-based override for manual toggle */
  .dark {
    --color-surface: oklch(0.145 0.017 264.364);
    --color-surface-raised: oklch(0.197 0.021 264.364);
    --color-surface-overlay: oklch(0.237 0.024 264.364);
    --color-text-primary: oklch(0.967 0.001 286.375);
    --color-text-secondary: oklch(0.551 0.027 264.364);
    --color-border: oklch(0.295 0.03 264.364);
    --color-border-strong: oklch(0.373 0.034 264.364);
    --color-accent: oklch(0.707 0.165 254.624);
    --color-accent-hover: oklch(0.623 0.214 259.815);
  }
}

Tip: Support both prefers-color-scheme and a manual .dark class so users can override their OS preference.

Tip: Dark mode shadows should be stronger — dark backgrounds absorb light, so subtle shadows disappear.

04

Build Core Components with Variant Patterns

Create reusable components that consume your design tokens through Tailwind utilities. Use a variant pattern where each component accepts a variant prop that maps to a set of classes. This keeps your component API clean while maintaining full Tailwind utility access for one-off overrides.

src/components/Button.tsxtsx
// src/components/Button.tsx
import { type ButtonHTMLAttributes, forwardRef } from 'react';

type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
}

const variantStyles: Record<ButtonVariant, string> = {
  primary:
    'bg-accent text-text-on-brand hover:bg-accent-hover shadow-sm active:shadow-none',
  secondary:
    'bg-surface-raised text-text-primary border border-border hover:border-border-strong shadow-sm',
  ghost:
    'bg-transparent text-text-primary hover:bg-gray-100 dark:hover:bg-gray-700',
  danger:
    'bg-red-600 text-white hover:bg-red-700 shadow-sm active:shadow-none',
};

const sizeStyles: Record<ButtonSize, string> = {
  sm: 'px-3 py-1.5 text-sm rounded-sm gap-1.5',
  md: 'px-4 py-2 text-base rounded-md gap-2',
  lg: 'px-6 py-3 text-lg rounded-lg gap-2.5',
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', className = '', children, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={`
          inline-flex items-center justify-center font-medium
          transition-all duration-150 ease-in-out
          focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent
          disabled:opacity-50 disabled:pointer-events-none
          ${variantStyles[variant]}
          ${sizeStyles[size]}
          ${className}
        `}
        {...props}
      >
        {children}
      </button>
    );
  }
);

Button.displayName = 'Button';

Tip: Use forwardRef on all components — consumers may need to attach refs for focus management or measurement.

Tip: Accept a className prop and append it last so consumers can override specific styles when needed.

05

Create Responsive Layout Primitives

Build layout components — Stack, Grid, Container — that handle spacing and responsive breakpoints consistently. These primitives eliminate repeated flexbox and grid patterns across your codebase. They consume your spacing tokens and provide a prop-based API for common layout needs.

src/layouts/Stack.tsxtsx
// src/layouts/Stack.tsx
import type { HTMLAttributes, ReactNode } from 'react';

type Spacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';

interface StackProps extends HTMLAttributes<HTMLDivElement> {
  direction?: 'vertical' | 'horizontal';
  gap?: Spacing;
  align?: 'start' | 'center' | 'end' | 'stretch';
  justify?: 'start' | 'center' | 'end' | 'between';
  wrap?: boolean;
  children: ReactNode;
}

const gapMap: Record<Spacing, string> = {
  xs: 'gap-(--spacing-xs)',
  sm: 'gap-(--spacing-sm)',
  md: 'gap-(--spacing-md)',
  lg: 'gap-(--spacing-lg)',
  xl: 'gap-(--spacing-xl)',
  '2xl': 'gap-(--spacing-2xl)',
  '3xl': 'gap-(--spacing-3xl)',
};

const alignMap: Record<string, string> = {
  start: 'items-start',
  center: 'items-center',
  end: 'items-end',
  stretch: 'items-stretch',
};

const justifyMap: Record<string, string> = {
  start: 'justify-start',
  center: 'justify-center',
  end: 'justify-end',
  between: 'justify-between',
};

export function Stack({
  direction = 'vertical',
  gap = 'md',
  align = 'stretch',
  justify = 'start',
  wrap = false,
  className = '',
  children,
  ...props
}: StackProps) {
  return (
    <div
      className={`
        flex
        ${direction === 'vertical' ? 'flex-col' : 'flex-row'}
        ${gapMap[gap]}
        ${alignMap[align]}
        ${justifyMap[justify]}
        ${wrap ? 'flex-wrap' : ''}
        ${className}
      `}
      {...props}
    >
      {children}
    </div>
  );
}

// src/layouts/Container.tsx
export function Container({
  size = 'md',
  className = '',
  children,
}: {
  size?: 'sm' | 'md' | 'lg' | 'full';
  className?: string;
  children: ReactNode;
}) {
  const maxWidth: Record<string, string> = {
    sm: 'max-w-2xl',
    md: 'max-w-5xl',
    lg: 'max-w-7xl',
    full: 'max-w-full',
  };

  return (
    <div className={`mx-auto w-full px-(--spacing-lg) ${maxWidth[size]} ${className}`}>
      {children}
    </div>
  );
}

Tip: Layout primitives with token-based gaps enforce consistent spacing without developers memorizing the scale.

Tip: Expose a className prop on layout components too — there will always be edge cases that need one-off adjustments.

06

Build a Component Showcase Page

Create an interactive showcase page that renders every component in every variant, size, and state. This serves as both documentation and a visual regression baseline. Include light/dark mode toggle and responsive viewport simulation to test all variations in one view.

src/pages/Showcase.tsxtsx
// src/pages/Showcase.tsx
import { useState } from 'react';
import { Button } from '../components/Button';
import { Stack } from '../layouts/Stack';
import { Container } from '../layouts/Container';

const variants = ['primary', 'secondary', 'ghost', 'danger'] as const;
const sizes = ['sm', 'md', 'lg'] as const;

export function Showcase() {
  const [dark, setDark] = useState(false);

  return (
    <div className={dark ? 'dark' : ''}>
      <div className="min-h-screen bg-surface text-text-primary transition-colors">
        <Container size="lg">
          <Stack gap="2xl" className="py-(--spacing-3xl)">
            <Stack direction="horizontal" justify="between" align="center">
              <h1 className="text-3xl font-bold">Design System</h1>
              <Button variant="ghost" onClick={() => setDark(!dark)}>
                {dark ? 'Light Mode' : 'Dark Mode'}
              </Button>
            </Stack>

            {/* Buttons */}
            <section>
              <h2 className="text-2xl font-semibold mb-(--spacing-lg)">Buttons</h2>
              <Stack gap="lg">
                {variants.map((variant) => (
                  <Stack key={variant} direction="horizontal" gap="md" align="center">
                    <span className="text-sm text-text-secondary w-24">{variant}</span>
                    {sizes.map((size) => (
                      <Button key={size} variant={variant} size={size}>
                        {size.toUpperCase()}
                      </Button>
                    ))}
                    <Button variant={variant} disabled>
                      Disabled
                    </Button>
                  </Stack>
                ))}
              </Stack>
            </section>

            {/* Color Palette */}
            <section>
              <h2 className="text-2xl font-semibold mb-(--spacing-lg)">Colors</h2>
              <div className="grid grid-cols-2 sm:grid-cols-4 gap-(--spacing-md)">
                {[
                  { name: 'Surface', className: 'bg-surface border border-border' },
                  { name: 'Raised', className: 'bg-surface-raised shadow-md' },
                  { name: 'Accent', className: 'bg-accent text-text-on-brand' },
                  { name: 'Border', className: 'bg-border' },
                ].map(({ name, className }) => (
                  <div key={name} className={`rounded-lg p-(--spacing-lg) ${className}`}>
                    <span className="text-sm font-medium">{name}</span>
                  </div>
                ))}
              </div>
            </section>
          </Stack>
        </Container>
      </div>
    </div>
  );
}

Tip: Render disabled, loading, and error states alongside default states — these are the ones that usually break.

Tip: Use the showcase page as a smoke test: if any component looks wrong here, it will look wrong in production.

07

Export and Package the Design System

Configure your package exports so consumers can import individual components and tokens. Use Vite's library mode to build the design system as a package with proper ESM and type declaration outputs. Publish to npm or a private registry for use across multiple projects.

vite.config.tstypescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    dts({ rollupTypes: true }),
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      formats: ['es'],
      fileName: 'index',
    },
    rollupOptions: {
      external: ['react', 'react-dom', 'react/jsx-runtime'],
    },
  },
});

// src/index.ts — barrel export
export { Button } from './components/Button';
export { Stack } from './layouts/Stack';
export { Container } from './layouts/Container';

// package.json additions:
// "main": "dist/index.js",
// "types": "dist/index.d.ts",
// "files": ["dist", "src/tokens"],
// "exports": {
//   ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
//   "./tokens": "./src/tokens/theme.css"
// }

Tip: Export your token CSS file separately so consumers can import it without the React components.

Tip: Mark react and react-dom as external so they aren't bundled into your library — consumers provide their own.

Next Steps

  • -Add Storybook for interactive component documentation with controls, accessibility testing, and visual snapshots.
  • -Implement a theme switcher that goes beyond dark mode — brand themes, high-contrast mode, and seasonal variants.
  • -Add animation tokens and a motion system using CSS @keyframes and Tailwind's animation utilities.
  • -Set up Chromatic or Percy for automated visual regression testing on every pull request.

Need help building this?

I've shipped production projects with these stacks. Let's build yours together.

Let's Talk