Back to Blog
Apr 14, 20263 min readOnuzulike Anthony

Building a Design System on Tailwind CSS v4

CSS custom properties instead of config, dark mode strategy, component variants with cva, and responsive design patterns.

Full-StackTailwindCSSDesign

Tailwind CSS v4 drops the JavaScript config file and moves entirely to CSS. The design system for NodDev and NodWatch is built on this new model — here's what changed and what stayed the same.

v4: CSS-First Configuration

In v4, theme tokens live in your CSS file, not tailwind.config.js:

css
/* app/globals.css */
@import "tailwindcss";
 
@theme {
  --color-background: #000000;
  --color-foreground: #ffffff;
  --color-muted: rgba(255, 255, 255, 0.5);
  --color-border: rgba(255, 255, 255, 0.1);
  --color-card: rgba(255, 255, 255, 0.03);
  --color-card-hover: rgba(255, 255, 255, 0.06);
 
  --font-sans: "Inter Variable", system-ui, sans-serif;
  --font-mono: "Fira Code", "Fira Mono", monospace;
 
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;
}

Reference them in JSX: className="bg-[var(--color-card)] border border-[var(--color-border)]".

Alternatively, v4 maps @theme variables to Tailwind classes automatically if you name them correctly — --color-muted becomes text-muted, bg-muted, etc.

Component Variants with CVA

class-variance-authority (CVA) manages component variants without wrestling with template literals:

ts
// components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
 
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-white text-black hover:bg-white/90",
        outline: "border border-white/20 bg-transparent text-white hover:border-white/40 hover:bg-white/5",
        ghost: "text-white/70 hover:bg-white/5 hover:text-white",
        destructive: "bg-red-500/10 text-red-400 hover:bg-red-500/20",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-9 px-4 text-sm",
        lg: "h-11 px-6 text-base",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "md",
    },
  }
);
 
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}
 
export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
  );
}

cn is a clsx + tailwind-merge combo that handles conditional classes and merges conflicting Tailwind classes correctly.

Dark Mode Strategy

NodDev and NodWatch are intentionally dark-only — no light mode toggle. This simplifies the CSS significantly:

css
/* No dark: prefix needed — everything is dark */
:root {
  --bg: #000;
  --fg: #fff;
}

If you need light mode support, use the dark: prefix with class strategy:

js
// tailwind.config.ts (for v3 — v4 does this in CSS)
darkMode: "class"
html
<html class="dark">

Toggle via a useTheme hook that adds/removes the class from <html>.

Responsive Design Patterns

Tailwind's breakpoints are mobile-first: sm:, md:, lg:, xl:. The most useful pattern for content grids:

tsx
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
  {movies.map((movie) => <MovieCard key={movie.id} movie={movie} />)}
</div>

For prose content, cap the width:

tsx
<article className="mx-auto max-w-2xl px-4 sm:px-6">

Container Queries

v4 supports container queries natively:

tsx
<div className="@container">
  <div className="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3">
    {/* Responds to the container width, not the viewport */}
  </div>
</div>

This is useful for cards that appear in sidebars (narrow) and main content areas (wide) — the grid adapts to its parent, not the screen.

Typography

For prose content (blog posts), use the @tailwindcss/typography plugin:

tsx
<article className="prose prose-invert prose-lg max-w-none
  prose-headings:font-semibold
  prose-p:text-white/72
  prose-a:text-white
  prose-code:text-white
  prose-pre:border prose-pre:border-white/10">
  <MDXRemote source={content} />
</article>

prose-invert flips all the typography defaults to work on dark backgrounds.

Animation

Tailwind includes basic animations (animate-spin, animate-pulse, animate-bounce). For custom animations, define them in the CSS:

css
@keyframes fade-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
 
@theme {
  --animate-fade-in: fade-in 0.3s ease-out;
}

Then use animate-fade-in as a class.

For more complex animations, motion (formerly Framer Motion) composes well with Tailwind — use Tailwind for static styles, motion for transitions and gestures.