Tailwind v4 Setup & Customization in Boilerplates 2026
TL;DR
- Tailwind v4 switched from
tailwind.config.jsto CSS-first configuration—all customization goes in your CSS file. - The
@themedirective replacestheme.extendin config—design tokens defined in CSS, not JavaScript. - shadcn/ui is Tailwind v4 compatible as of its 2025 update—the component ecosystem has adapted.
- Dark mode in v4: use the
darkvariant with CSS variables—the approach is cleaner than v3. @import "tailwindcss"replaces the old three-line import (@tailwind base; @tailwind components; @tailwind utilities).- Performance improvement: v4 generates only the CSS you use, no configuration file scanning required.
Key Takeaways
- Tailwind v4 is a complete rewrite—it uses Lightning CSS internally and has no JavaScript config file by default.
tailwind.config.jsstill works via the compatibility layer (@config "./tailwind.config.js") but you should migrate to CSS config.- The
@layerdirective still exists but works differently in v4—CSS@layeris now native browser cascade layers. - CSS variables are now first-class in Tailwind:
var(--color-primary)and the--*utilities make custom properties ergonomic. - Breakpoints can be defined in CSS:
--breakpoint-3xl: 1920pxadds a3xl:variant. - Container queries (formerly a plugin) are built-in:
@containerand@md:text-lgwork natively.
What Actually Changed in Tailwind v4
Most articles covering Tailwind v4 focus on the new features without honestly accounting for what changed for projects migrating from v3. If you are evaluating a boilerplate or planning an upgrade, understanding the actual scope of change matters more than the feature list.
The most significant architectural change is that tailwind.config.ts is no longer the primary customization point. In v3, all customization — colors, fonts, spacing scale extensions, custom plugins, dark mode configuration — lived in tailwind.config.js. In v4, this moves to CSS. Your globals.css file is now where you define design tokens using the @theme directive, configure variants using @custom-variant, and load plugins using @plugin. The JavaScript configuration file is not removed — it still works via a compatibility layer (@config "./tailwind.config.js") — but the intent is for new projects to use the CSS-first approach.
The PostCSS plugin also changed. In v3, you used tailwindcss as the PostCSS plugin. In v4, you use @tailwindcss/postcss. If you are upgrading a project with an existing postcss.config.mjs, this is a required manual change:
// v3
const config = { plugins: { tailwindcss: {} } };
// v4
const config = { plugins: { "@tailwindcss/postcss": {} } };
The import syntax changed. The v3 three-directive approach (@tailwind base; @tailwind components; @tailwind utilities) is replaced by a single @import "tailwindcss" statement. This is not just syntactic: the single import tells Tailwind v4's engine exactly where to inject CSS, and the engine itself works differently — it scans your source files using a fast Rust-based implementation rather than the previous JavaScript-based approach.
The JIT engine improvements in v4 are real and noticeable for large projects. In v3, the dev server would rebuild styles on every change; in v4, style updates are incremental and significantly faster. For projects with large component libraries, the difference in development server responsiveness is perceptible. Production CSS generation is also faster, though this matters less since it only happens at build time.
What the Official Migration Codemod Handles
The @tailwindcss/upgrade codemod automates most of the migration from v3 to v4. It converts tailwind.config.js theme extensions into @theme blocks in your CSS, updates the import syntax, migrates deprecated utility class names (Tailwind v4 renamed several utilities for consistency), and updates the PostCSS configuration.
What the codemod does not handle: custom plugins that used the v3 plugin API. The v3 plugin API used JavaScript functions to add utilities and components. The v4 equivalent uses @plugin with CSS, and the two APIs are not structurally equivalent. Custom plugins need to be rewritten manually, or replaced with direct CSS using @utility. For most projects with simple custom utilities, this is a small amount of work. For projects with complex custom plugins that generated many utilities programmatically, it requires more effort.
Building a Component Variant System with Tailwind v4
The most common complaint about Tailwind in component-heavy applications is that conditional class logic becomes unwieldy. A button that has three variants, three sizes, two states, and combinations thereof ends up with complex ternary expressions or string concatenation that is hard to read and maintain. This is a real problem, and it predates v4 — but v4's design token system makes the solution patterns more effective.
The two established approaches to component variants in the Tailwind ecosystem are class-variance-authority (CVA) and tailwind-variants. Both provide a typed API for defining component variants and composing class lists. CVA is older and more widely adopted; tailwind-variants is newer with a similar API and some additional features like compound variants and responsive variants.
A button component with CVA looks like this:
// components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
// Base classes applied to all variants
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
export function Button({ className, variant, size, isLoading, children, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? <Spinner className="mr-2 h-4 w-4" /> : null}
{children}
</button>
);
}
The VariantProps<typeof buttonVariants> utility extracts TypeScript types directly from the CVA definition, so your component's props are automatically typed with the correct variant values. TypeScript will error if you pass variant="invalid" — you get autocomplete and type safety without writing the types manually.
The reason you want this in your component layer rather than scattered conditional className strings is maintainability. When the design changes and the "primary" variant needs to be bg-brand-600 instead of bg-primary, you change it in one place in the CVA definition and it propagates everywhere. When you scatter variant logic across every callsite, that change requires finding and updating every instance.
The cn utility (typically combining clsx and tailwind-merge) handles the other common problem: merging className strings from the component definition with className strings passed as props. tailwind-merge understands Tailwind class semantics and correctly resolves conflicts when both the component and the caller provide classes that affect the same CSS property.
Performance: What Tailwind v4 Fixes and What It Doesn't
The performance conversation around Tailwind has two distinct axes: production CSS bundle size and development server performance. v4 makes genuine improvements on both, but the improvements are different in character.
On production bundle size, Tailwind's approach of only including utility classes that are actually used in your source files means that the CSS output is proportional to how much of the design system you use. A typical SaaS application that uses a reasonably broad range of spacing, color, and typography utilities produces 15-30KB of CSS before compression, and 5-10KB gzipped. This is significantly smaller than what traditional CSS frameworks produced, and comparable to hand-written CSS for a similar feature set.
The common claim that "Tailwind produces huge CSS bundles" reflects a misunderstanding: it applies to v2 and earlier, which generated the full set of possible utility combinations. v3's JIT compiler and v4's native scanning both produce only the classes actually used. For most projects, Tailwind's CSS output is not a performance concern.
On development server performance, v4's new engine — written in Rust and using Lightning CSS — is measurably faster for large projects. The critical difference is incremental compilation: when you change a component file, v4 only regenerates the CSS for classes in that file rather than scanning all source files. For projects with hundreds of components, this translates into noticeably snappier style updates during development.
What v4 does not fix is the verbosity of utility classes in JSX. A component with many style conditions still produces long class strings, and this is a legitimate ergonomic concern. The CVA/tailwind-variants approach described above is the standard mitigation — extract variant logic into typed definitions rather than writing long ternary expressions inline. The Prettier plugin for Tailwind (prettier-plugin-tailwindcss) handles class ordering automatically, which addresses the "different developers write classes in different orders" problem. Include it as a devDependency and configure it in your Prettier config; it should be a required CI check alongside linting.
The "utility hell" perception of Tailwind — the idea that long class lists make code hard to read — is worth addressing honestly. Tailwind utility classes in JSX are verbose. A component with meaningful interactivity, responsive behavior, dark mode support, and multiple states will have a long className string. The counterargument from the Tailwind community is that this verbosity is better than the alternative of hunting through separate CSS files and wondering what class applies to what element. This is a legitimate trade-off, not a clean win for either approach. The key practice that makes Tailwind-heavy code readable is consistent component extraction: styling logic belongs in dedicated component files, not inline at every callsite.
Dark Mode and Custom Colors in Tailwind v4
Tailwind v4 introduces OKLCH as the recommended color space for defining custom colors, and this is worth understanding because it is a genuine improvement over the HSL approach common in v3-era configurations.
HSL (hue, saturation, lightness) has a perceptual uniformity problem: colors at the same "lightness" value in HSL look noticeably different in terms of perceived brightness depending on their hue. A yellow at HSL 50% lightness looks much brighter than a blue at the same lightness value. This makes it difficult to create color palettes where shades feel visually consistent.
OKLCH (the OK prefix stands for the perceptual uniformity improvement over the original LCH model) is designed to have consistent perceived lightness across hues. A blue and a yellow at the same OKLCH lightness value will look similar in perceived brightness to human vision. This makes color system design more predictable: if you want a "brand-500" that looks comparable in weight to a "neutral-500," using OKLCH values makes this much easier to achieve consistently.
The practical benefit for boilerplates is that you can define an entire color palette in OKLCH and expect consistent visual results across the hue range:
@theme {
--color-brand-50: oklch(97% 0.01 250);
--color-brand-100: oklch(93% 0.03 250);
--color-brand-200: oklch(88% 0.06 250);
--color-brand-300: oklch(78% 0.10 250);
--color-brand-400: oklch(68% 0.15 250);
--color-brand-500: oklch(58% 0.18 250);
--color-brand-600: oklch(48% 0.17 250);
--color-brand-700: oklch(38% 0.14 250);
--color-brand-800: oklch(28% 0.10 250);
--color-brand-900: oklch(20% 0.07 250);
--color-brand-950: oklch(13% 0.04 250);
}
The Semantic Color Token Approach
Rather than using raw color values in component classes (bg-brand-500 text-brand-50), the pattern that works best with dark mode is semantic tokens. Semantic tokens give colors names based on their purpose rather than their value: bg-primary instead of bg-brand-500, text-muted-foreground instead of text-gray-400. This approach means components do not need to specify different classes for light and dark mode — they always use bg-background and text-foreground, and the CSS variable values change based on the active theme.
shadcn/ui's design system is built entirely on this semantic token approach, which is why its components work correctly in dark mode without each component needing explicit dark: prefixed classes.
The Flash of Unstyled Theme
The most common dark mode implementation bug is the "flash of unstyled theme" (or more precisely, the flash of the wrong theme) on initial page load. This happens because the theme preference is stored in localStorage, which is only accessible on the client. When Next.js server-renders the page, it has no access to localStorage and renders with the default (light) theme. When the JavaScript bundle loads on the client and reads localStorage, it switches to dark mode. Users on dark mode see a brief flash of light mode on every page load.
The standard fix is an inline script in the <head> that runs synchronously before any content renders, reads the theme from localStorage, and sets the appropriate class on <html>:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('theme');
const system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
const theme = stored || system;
document.documentElement.classList.toggle('dark', theme === 'dark');
} catch(e) {}
})();
`
}} />
</head>
<body>{children}</body>
</html>
);
}
The suppressHydrationWarning on <html> is necessary because the server-rendered HTML will have a different class attribute than the client-side hydrated HTML (the server does not know the user's theme preference). Without this prop, React logs a hydration mismatch warning.
The try/catch wrapper handles the case where localStorage is unavailable (private browsing mode in some browsers, or when JavaScript is restricted). The inline script must be synchronous — do not defer it or load it asynchronously, because deferred scripts run after the page renders and will not prevent the flash.
Installing Tailwind v4
npm install tailwindcss @tailwindcss/vite
# OR for Next.js:
npm install tailwindcss @tailwindcss/postcss postcss
PostCSS Configuration (Next.js)
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
CSS Entry Point
/* src/app/globals.css */
@import "tailwindcss";
That's the entire setup for default Tailwind v4. No tailwind.config.js required.
The @theme Directive
All design tokens live in your CSS file under @theme:
/* src/app/globals.css */
@import "tailwindcss";
@theme {
/* Colors */
--color-brand-50: oklch(97% 0.02 250);
--color-brand-100: oklch(93% 0.04 250);
--color-brand-500: oklch(60% 0.18 250);
--color-brand-900: oklch(25% 0.08 250);
/* Typography */
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* Spacing (extend the scale) */
--spacing-18: 4.5rem;
--spacing-22: 5.5rem;
/* Border radius */
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
/* Breakpoints */
--breakpoint-3xl: 1920px;
}
Generated utilities from these tokens:
<!-- Color utilities -->
<div class="bg-brand-500 text-brand-50">
<!-- Spacing utilities -->
<div class="mt-18 pb-22">
<!-- Custom breakpoint -->
<div class="3xl:max-w-screen-3xl">
shadcn/ui with Tailwind v4
shadcn/ui's 2025 update added full Tailwind v4 support. The initialization and token system changed.
Setting Up shadcn/ui with v4
npx shadcn@latest init
# Select: Tailwind v4 (when prompted)
The generated globals.css uses @theme for design tokens:
/* globals.css */
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--radius: 0.5rem;
/* ... all other tokens */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... dark mode tokens */
}
Using @theme inline
The inline modifier tells Tailwind not to generate the token itself as a utility, but to map it to the specified CSS variable:
@theme inline {
--color-primary: var(--primary);
}
This means bg-primary generates background-color: var(--primary) in the CSS—the value comes from the :root variable, enabling runtime theming without class changes.
Dark Mode Setup
Tailwind v4 dark mode with CSS variables:
Configuration
/* globals.css */
@import "tailwindcss";
/* v4 dark mode config */
@custom-variant dark (&:where(.dark, .dark *));
Or using the dark class on <html>:
@variant dark (&:where(.dark *));
Theme Toggle Implementation
// components/theme-toggle.tsx
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const saved = localStorage.getItem("theme") as "light" | "dark" | null;
const preferred = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const initial = saved ?? preferred;
setTheme(initial);
document.documentElement.classList.toggle("dark", initial === "dark");
}, []);
const toggle = () => {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.toggle("dark", next === "dark");
};
return (
<Button variant="ghost" size="icon" onClick={toggle}>
{theme === "light" ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
);
}
System Theme Detection (No Flash)
Prevent flash of wrong theme with an inline script in <head>:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
})()
`
}} />
</head>
<body>{children}</body>
</html>
);
}
Custom Utilities with @utility
Define custom utility classes in v4:
@utility text-balance {
text-wrap: balance;
}
@utility grid-cols-auto-fill {
grid-template-columns: repeat(auto-fill, minmax(var(--min-col-width, 250px), 1fr));
}
@utility container-query-card {
container-type: inline-size;
}
Usage:
<p class="text-balance">This heading has balanced wrapping.</p>
<div class="container-query-card">
<p class="@md:text-lg @lg:text-xl">Responds to container size</p>
</div>
Container Queries (Built-in in v4)
Container queries are built into v4—no plugin required:
<div class="@container">
<div class="@md:grid @md:grid-cols-2">
<!-- Responds to container width, not viewport -->
</div>
</div>
This enables truly reusable components that adapt to their context rather than the viewport.
Migrating from v3
If your boilerplate uses Tailwind v3, the upgrade path:
npm install tailwindcss@latest
# Follow the v4 migration guide
npx @tailwindcss/upgrade@next
The upgrade tool handles most of the migration automatically:
- Converts
tailwind.config.jstheme extensions to@themein your CSS - Updates import syntax
- Migrates deprecated utilities
Key Breaking Changes
| v3 | v4 |
|---|---|
@tailwind base | Removed (included in @import) |
theme.extend.colors in config | @theme { --color-*: ... } in CSS |
darkMode: "class" in config | @custom-variant dark in CSS |
plugins: [require('@tailwindcss/forms')] | @plugin "@tailwindcss/forms" in CSS |
For how these Tailwind patterns integrate with your component library, see best design system starter kits 2026. For database and auth setups that complement this styling layer, see database setup: Prisma vs Drizzle in boilerplates. For the complete boilerplate selection with v4 support, see best Next.js boilerplates 2026.
Methodology
Based on Tailwind CSS v4 official documentation, shadcn/ui v4 migration guides, and hands-on testing with Next.js 15 + App Router. Dark mode patterns from community implementations and Next.js official dark mode documentation.