AI Reading
Quick summary of this article
Style a Next.js 16 application with Tailwind CSS 4: the new CSS-first config, @theme tokens, responsive layouts, dark mode without a config file, and reusable components.
- Tailwind CSS 4 needs no
tailwind.config.js— you configure it in CSS with@import "tailwindcss"and the@themeblock. - Class-based dark mode requires the
@custom-variant darkdirective because v4 defaults to the operating system preference. - Tailwind is mobile-first: unprefixed utilities apply to phones, and
md:/lg:add larger-screen rules. - Design tokens declared in
@themebecome real CSS variables, which you can use in inline styles, charts and canvas.
Complete Next.js 16 Series — Module 3 of 12
create-next-app already installed Tailwind CSS 4. What changed in v4 is where configuration lives: it moved out of JavaScript and into CSS. There is no tailwind.config.js any more, and postcss.config.mjs contains a single plugin.
What Tailwind 4 Actually Looked Like in Setup
If you scaffolded with create-next-app, src/app/globals.css starts like this:
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
}
And postcss.config.mjs:
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
That is the whole setup. No content array — Tailwind 4 detects your source files automatically. No @tailwind base; @tailwind components; @tailwind utilities; — a single @import "tailwindcss"; replaces all three.
Designing with @theme Tokens
The @theme block defines your design system. Every key you add becomes a CSS variable and a utility class:
@import "tailwindcss";
@theme {
--color-brand-50: #eef6ff;
--color-brand-500: #2f7cf6;
--color-brand-600: #1f63d0;
--color-brand-900: #0e2f66;
--radius-card: 1rem;
--font-display: "Satoshi", system-ui, sans-serif;
}
You can now write bg-brand-500, text-brand-900, rounded-card and font-display. Those values are also available as var(--color-brand-500) if you need them in a chart or inline style.
Prefer
@themeover arbitrary values likebg-[#2f7cf6]. Arbitrary values work, but they are not reusable and they cannot be themed.
Mobile-First Responsive Layouts
Tailwind breakpoints are min-width based. Unprefixed utilities target phones:
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card title="Analytics" />
<Card title="Billing" />
<Card title="Members" />
</div>
Read that as: one column by default, two from 640px, three from 1024px. In Tailwind 4 the default breakpoints are sm 40rem, md 48rem, lg 64rem, xl 80rem and 2xl 96rem.
A page shell that behaves well at every size:
export default function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-dvh bg-neutral-50 dark:bg-neutral-950">
<header className="sticky top-0 z-10 border-b border-neutral-200 bg-white/80 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/80">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<span className="font-display text-lg font-bold">Next.js 16</span>
<nav className="hidden gap-6 text-sm md:flex">
<a href="/docs">Docs</a>
<a href="/pricing">Pricing</a>
</nav>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-8 lg:py-12">{children}</main>
</div>
);
}
Note min-h-dvh instead of min-h-screen — on mobile browsers the dynamic viewport unit avoids the URL-bar jump.
Dark Mode with the Class Strategy
Tailwind 4 defaults to prefers-color-scheme: dark. To let users choose, switch to the class strategy with a custom variant:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Now dark: utilities activate whenever a .dark class exists on any ancestor. Add the class to <html>:
// src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="antialiased">{children}</body>
</html>
);
}
The class itself is applied by a tiny Client Component. Create src/components/ThemeScript.tsx to avoid a flash of the wrong theme:
export default function ThemeScript() {
const code = `
try {
const stored = localStorage.getItem('theme');
const dark = stored ? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
} catch {}
`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}
Render it inside <head>:
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body className="antialiased">{children}</body>
</html>
An inline script runs before the first paint, so the correct theme is on screen from the first frame. The toggle itself is a Client Component:
"use client";
import { useEffect, useState } from "react";
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
}, []);
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
}
return (
<button
onClick={toggle}
aria-label="Toggle theme"
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700"
>
{dark ? "☀️ Light" : "🌙 Dark"}
</button>
);
}
Reusable Components Without a UI Library
Slots, variants and conditional classes are enough for most design systems:
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "ghost" | "danger";
};
const variants = {
primary:
"bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600",
ghost:
"bg-transparent text-neutral-700 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800",
danger: "bg-red-600 text-white hover:bg-red-700",
} as const;
export function Button({ variant = "primary", className = "", ...props }: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
disabled:pointer-events-none disabled:opacity-50 ${variants[variant]} ${className}`}
{...props}
/>
);
}
That component is a Server Component — no "use client". It is reusable in both server and client trees because it holds no state.
When to Use CSS Modules Instead
Tailwind handles 90% of styling, but genuinely scoped CSS still has a place — complex keyframe animations, or porting an existing stylesheet. Next.js 16 supports CSS Modules out of the box:
/* src/components/Spinner.module.css */
.spin {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
import styles from "./Spinner.module.css";
export default function Spinner() {
return <div className={`${styles.spin} h-6 w-6 rounded-full border-2 border-brand-600 border-t-transparent`} />;
}
Class names are hashed and scoped, so they never leak. Mixing Tailwind and CSS Modules in one codebase is normal and supported.
Module 3 Recap
- Tailwind 4 is configured in CSS:
@import "tailwindcss"plus@theme. @themetokens generate both CSS variables and utility classes.- Tailwind is mobile-first; layer breakpoints with
sm:md:lg:. - Class-based dark mode needs
@custom-variant darkand an inline theme script to prevent flashing. - Component variants via a
variantsmap keep styling consistent without a UI library.
Next up — Module 4: dynamic routes, async params, searchParams, generateStaticParams and generateMetadata.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 2: App Router, Layouts & Navigation
- Next: Module 4: Dynamic Routes & SEO Metadata
