Module 3: Styling with Tailwind CSS 4 — Responsive Layouts & Dark Mode in Next.js 16

Module 3: Styling with Tailwind CSS 4 — Responsive Layouts & Dark Mode in Next.js 16

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 @theme block.
  • Class-based dark mode requires the @custom-variant dark directive 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 @theme become 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 @theme over arbitrary values like bg-[#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.
  • @theme tokens generate both CSS variables and utility classes.
  • Tailwind is mobile-first; layer breakpoints with sm: md: lg:.
  • Class-based dark mode needs @custom-variant dark and an inline theme script to prevent flashing.
  • Component variants via a variants map keep styling consistent without a UI library.

Next up — Module 4: dynamic routes, async params, searchParams, generateStaticParams and generateMetadata.

Leave a Reply

Your email address will not be published. Required fields are marked *