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.
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:
/* 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:
// 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:
/* 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:
// tailwind.config.ts (for v3 — v4 does this in CSS)
darkMode: "class"<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:
<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:
<article className="mx-auto max-w-2xl px-4 sm:px-6">Container Queries
v4 supports container queries natively:
<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:
<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:
@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.