Skip to main content

Building a Design System with Tailwind CSS v4

A practical guide to creating consistent, scalable UI components

IqbalJun 15, 20264 min read

Why Build a Design System?

Every growing project eventually faces the same problem: inconsistency. Buttons look different across pages, spacing is unpredictable, and colors drift from the brand palette. A design system solves this by establishing a single source of truth for your UI.

With Tailwind CSS v4, building a design system has become more intuitive thanks to the new CSS-first configuration approach. Let’s walk through how to set one up from scratch.

Setting Up Design Tokens

Design tokens are the atomic values that define your visual language. In Tailwind v4, we define them directly in CSS using @theme.

Color Tokens

@theme {
  --color-primary: oklch(0.7 0.15 180);
  --color-primary-hover: oklch(0.65 0.15 180);
  --color-surface: oklch(0.15 0.02 260);
  --color-border: oklch(0.3 0.02 260);
  --color-text-main: oklch(0.95 0.01 260);
  --color-text-muted: oklch(0.7 0.02 260);
}

Spacing and Typography

@theme {
  --font-heading: 'Geist', system-ui, sans-serif;
  --font-body: 'Geist', system-ui, sans-serif;
  --font-mono: 'Geist Mono', ui-monospace, monospace;

  --spacing-section: 6rem;
  --spacing-card: 1.5rem;
  --radius-card: 1rem;
}

The beauty of this approach is that your tokens are real CSS custom properties. They work with the cascade, can be overridden per-component, and are visible in browser DevTools.

Component Patterns

Once your tokens are in place, you can build reusable component patterns. Here are a few essential ones.

The Button Component

A good button component handles multiple variants without duplicating styles:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
}

const Button = ({ variant = 'primary', size = 'md', children }: ButtonProps) => {
  const baseClasses = 'inline-flex items-center font-medium transition-all rounded-xl';

  const variants = {
    primary: 'bg-primary text-white hover:bg-primary-hover',
    secondary: 'border border-border bg-surface text-text-main hover:border-primary',
    ghost: 'text-text-muted hover:text-text-main hover:bg-surface/50',
  };

  const sizes = {
    sm: 'px-3 py-1.5 text-xs',
    md: 'px-5 py-2.5 text-sm',
    lg: 'px-7 py-3 text-base',
  };

  return (
    <button className={`${baseClasses} ${variants[variant]} ${sizes[size]}`}>{children}</button>
  );
};

The Card Component

Cards benefit from the glassmorphism effect that’s popular in modern dark UIs:

const Card = ({ children, className }: { children: React.ReactNode; className?: string }) => (
  <div
    className={`rounded-xl border border-border/50 bg-surface/50 p-card backdrop-blur-sm ${className}`}
  >
    {children}
  </div>
);

Badge Component

Badges are great for tags, status indicators, and labels:

const Badge = ({ children, href }: { children: React.ReactNode; href?: string }) => {
  const Component = href ? 'a' : 'span';
  return (
    <Component
      href={href}
      className="inline-flex items-center rounded-full border border-border px-3 py-1 text-xs font-medium"
    >
      {children}
    </Component>
  );
};

Theming Strategy

Dark Mode First

For developer-focused sites, I recommend starting with dark mode as the default. Tailwind v4 makes this straightforward:

@custom-variant dark (&:where(.dark, .dark *));

Then define your light mode overrides:

:root {
  color-scheme: dark;
}

.light {
  --color-surface: oklch(0.98 0.01 260);
  --color-text-main: oklch(0.15 0.02 260);
  --color-text-muted: oklch(0.4 0.02 260);
}

Responsive Design Tokens

Some tokens should change at different breakpoints. While CSS custom properties don’t directly support media queries in @theme, you can use standard CSS:

:root {
  --spacing-section: 3rem;
}

@media (min-width: 768px) {
  :root {
    --spacing-section: 5rem;
  }
}

@media (min-width: 1024px) {
  :root {
    --spacing-section: 6rem;
  }
}

Animation and Motion

Consistent animation patterns make your UI feel cohesive. I use a spring-based system:

const springConfig = {
  type: 'spring' as const,
  stiffness: 350,
  damping: 30,
};

// Entrance animation
const fadeInUp = {
  initial: { opacity: 0, y: 20 },
  animate: { opacity: 1, y: 0 },
  transition: springConfig,
};

Respecting User Preferences

Always check for prefers-reduced-motion:

import { useReducedMotion } from 'framer-motion';

const AnimatedComponent = ({ children }) => {
  const shouldReduce = useReducedMotion();

  return (
    <motion.div
      initial={shouldReduce ? false : { opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={shouldReduce ? { duration: 0 } : springConfig}
    >
      {children}
    </motion.div>
  );
};

Testing Your Design System

Property-based testing works well for design system invariants:

import * as fc from 'fast-check';

// Every button variant should produce valid class strings
fc.assert(
  fc.property(
    fc.constantFrom('primary', 'secondary', 'ghost'),
    fc.constantFrom('sm', 'md', 'lg'),
    (variant, size) => {
      const classes = getButtonClasses(variant, size);
      expect(classes).toContain('inline-flex');
      expect(classes).toContain('rounded-xl');
    }
  )
);

Key Takeaways

  1. Start with tokens — define your visual language before building components
  2. Use CSS-first config — Tailwind v4’s @theme makes tokens real CSS properties
  3. Build composable components — variants and sizes should combine cleanly
  4. Respect motion preferences — always check prefers-reduced-motion
  5. Test invariants — property-based testing catches edge cases in component logic

A good design system isn’t about restricting creativity — it’s about providing a solid foundation so you can focus on solving real problems instead of debating pixel values.


What design system patterns have worked well for you? Drop a comment below.

Comments

Leave a comment