Skip to main content
All articles
3 min read

Tailwind CSS v4: Building a SaaS Design System in One Day

CSS tokens, dark mode, reusable components: how to structure a professional design system with Tailwind v4 and shadcn/ui.

Why a design system from day one?

A SaaS without a design system always ends up the same way: hardcoded colors everywhere, a broken dark mode, and components copy-pasted instead of reused.

With Tailwind CSS v4 and shadcn/ui, you can put a solid system in place in under a day. Here's how HeartCo did it.

Step 1: CSS tokens

Tailwind v4 introduces native CSS tokens. No more tailwind.config.js for colors: everything goes through CSS variables:

/* globals.css */
@theme {
  --color-brand: #4f46e5;
  --color-deep-navy: #0d1b2a;
  --color-ai-blue: #3b82f6;
  --color-ai-cyan: #06b6d4;
}

The payoff: dark mode becomes trivial

:root {
  --color-background: oklch(0.984 0.003 247.858);
  --color-foreground: oklch(0.141 0.005 285.823);
}
 
.dark {
  --color-background: oklch(0.141 0.005 285.823);
  --color-foreground: oklch(0.984 0.003 247.858);
}

Your components use bg-background and text-foreground, and the theme switches automatically. Zero dark: prefixes to manage by hand.

Step 2: the component library

shadcn/ui isn't an npm dependency, it's a code generator. You copy the components into your project and customize them:

npx shadcn@latest add button card dialog

The secret: never modify the primitives directly. Build business-specific wrappers instead:

// src/components/ui/heart-button.tsx
import { Button, type ButtonProps } from "~/components/ui/button";
import { cn } from "~/lib/utils";
 
export function HeartButton({
  variant = "default",
  className,
  ...props
}: ButtonProps) {
  return (
    <Button
      className={cn(
        "transition-all duration-200",
        "hover:scale-[1.02] active:scale-[0.98]",
        variant === "primary" &&
          "bg-brand text-white shadow-[0_0_20px_rgba(99,102,241,0.3)]",
        className,
      )}
      {...props}
    />
  );
}

Step 3: the cn() function, your best friend

// src/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

cn() solves Tailwind's classic problem: when two conflicting classes meet (p-4 + p-6), twMerge keeps the last one. Without it, style overrides are unpredictable.

Step 4: the design system's rules

A few rules HeartCo applies strictly:

  1. Never hardcode colors: always use tokens (text-foreground, not text-gray-900)
  2. Never use a dark: prefix, the tokens handle the theme
  3. Hover states are mandatory: every interactive element gives visual feedback
  4. Cards use shadows, not borders: more modern, more elegant
  5. Headings use a display font: visually separate heading from body

The result

With this structure, adding a new component takes 5 minutes instead of 30. Dark mode works everywhere. And your SaaS looks professional from day one.

Go further

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee