Tailwind v4 + Next.js (App Router) + React Light/Dark Mode Architecture
This document explains how to implement a robust, zero-flicker light/dark theme with Tailwind CSS v4, CSS custom properties (OKLCH), and next-themes. It also shows exactly how the header toggle is wired with proper bar-themed colors.
- Tailwind v4 maps CSS variables to semantic utilities (
bg-background,text-foreground, etc.). next-themesadds and persists the theme class on the root element and handles SSR to avoid hydration issues.- A small
ThemeSwitchercycles Light → Dark → System and lives in the global header. - Custom amber/orange color palette creates a warm bar atmosphere.
1) Global CSS and Tokens (app/globals.css)
We use OKLCH for perceptual uniformity and expose design tokens as CSS variables. Tailwind v4's @theme inline maps these variables to utilities, and a custom dark variant ensures the class strategy.
```css @import 'tailwindcss';
/* Ensure tailwind's dark: variant is driven by a .dark class on */ @custom-variant dark (&:is(.dark *));
:root { /* Light theme - Warm bar atmosphere */ --background: oklch(0.98 0.01 60); --foreground: oklch(0.15 0.02 30); --card: oklch(1.0 0 0); --card-foreground: oklch(0.15 0.02 30); --popover: oklch(1.0 0 0); --popover-foreground: oklch(0.15 0.02 30); --primary: oklch(0.65 0.15 45); --primary-foreground: oklch(0.98 0.01 60); --secondary: oklch(0.96 0.02 50); --secondary-foreground: oklch(0.25 0.03 35); --muted: oklch(0.95 0.02 55); --muted-foreground: oklch(0.45 0.03 40); --accent: oklch(0.92 0.03 50); --accent-foreground: oklch(0.25 0.03 35); --destructive: oklch(0.62 0.18 25); --destructive-foreground: oklch(0.98 0.01 60); --border: oklch(0.88 0.02 50); --input: oklch(0.95 0.02 55); --ring: oklch(0.65 0.15 45);
/* Fonts */ --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); }
.dark { /* Dark theme - Cozy bar lighting */ --background: oklch(0.08 0.02 30); --foreground: oklch(0.92 0.01 60); --card: oklch(0.12 0.02 35); --card-foreground: oklch(0.92 0.01 60); --popover: oklch(0.10 0.02 32); --popover-foreground: oklch(0.92 0.01 60); --primary: oklch(0.70 0.15 45); --primary-foreground: oklch(0.08 0.02 30); --secondary: oklch(0.15 0.02 35); --secondary-foreground: oklch(0.85 0.01 55); --muted: oklch(0.18 0.02 38); --muted-foreground: oklch(0.65 0.02 45); --accent: oklch(0.22 0.03 40); --accent-foreground: oklch(0.85 0.01 55); --destructive: oklch(0.62 0.18 25); --destructive-foreground: oklch(0.92 0.01 60); --border: oklch(0.25 0.02 40); --input: oklch(0.18 0.02 38); --ring: oklch(0.70 0.15 45); }
@theme inline { /* Map CSS variables to Tailwind tokens */ --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-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-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring);
--font-sans: var(--font-sans); --font-mono: var(--font-mono); }
@layer base {
- { @apply border-border; } body { @apply bg-background text-foreground; } } ```
2) App Router Integration (app/layout.tsx)
We use ThemeProvider from next-themes with attribute="class" so the .dark class lands on <html>. Fonts are bound to CSS variables and applied globally.
```tsx import type { Metadata } from "next"; import { Geist, Geist_Mono } from 'next/font/google'; import "./globals.css"; import { ThemeProvider } from "next-themes"; import { ClerkProvider } from "@clerk/nextjs"; import { ConvexClientProvider } from "@/components/convex-client-provider"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
${geistSans.variable} ${geistMono.variable}}>
Notes:
suppressHydrationWarninghelps avoid warnings whennext-themesswaps the class on hydration.defaultTheme="system"makes the first paint respect the OS theme.disableTransitionOnChangeprevents jarring animations during theme switches.
3) Theme Switcher (components/theme-switcher.tsx)
The switcher cycles Light → Dark → System with proper icons and defers rendering until mounted to avoid client/server mismatch.
```tsx "use client"
import { useTheme } from "next-themes" import { useEffect, useState } from "react" import { Moon, Sun, Monitor } from 'lucide-react'
export function ThemeSwitcher() { const [mounted, setMounted] = useState(false) const { theme, setTheme } = useTheme()
useEffect(() => { setMounted(true) }, [])
if (!mounted) { return (
const toggleTheme = () => { if (theme === "light") { setTheme("dark") } else if (theme === "dark") { setTheme("system") } else { setTheme("light") } }
const getIcon = () => {
if (theme === "light") return
const getTitle = () => { if (theme === "light") return "Switch to dark mode" if (theme === "dark") return "Switch to system mode" return "Switch to light mode" }
return ( ) } ```
Key features:
- Proper mounting guard prevents hydration mismatches
- Three-state cycle: Light → Dark → System
- Accessible with proper ARIA labels and titles
- Smooth transitions with hover states
4) Header Integration (components/header.tsx)
The theme switcher is placed in the header alongside authentication components.
```tsx import { ThemeSwitcher } from "./theme-switcher" import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs" // ... other imports
export function Header() {
return (
)
}
``` This implementation provides a robust, accessible theme system with a warm bar atmosphere that works seamlessly across all components and prevents common React hydration issues. Ready to build? Go from idea to launched product in a week with AI-assisted development. {/* Right: Theme switcher and auth */}
<div className="flex items-center gap-4">
<ThemeSwitcher />
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
</div>
</header>
5) Key Differences from Original Implementation
What Works Now:
Critical Fixes Applied:
mounted state managementnext-themes properly saves and restores theme choicesuppressHydrationWarning and mounting guards prevent mismatches
6) Usage Patterns
className="bg-background text-foreground"className="bg-card text-card-foreground border border-border rounded-lg"hover:bg-accent hover:text-accent-foreground focus-visible:outline-ringdark:shadow-xl, dark:bg-secondarytext-amber-600 dark:text-amber-400
7) Troubleshooting
ThemeProvider wraps the entire app and has attribute="class"mounted state is properly managed in ThemeSwitchersuppressHydrationWarning on <html> and mounting guardsdefaultTheme="system" and proper provider setup