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. CSS CSS (Cascading Style Sheets) is a stylesheet language used for describing the presentation of a document written in HTML. Google Fonts Google Fonts is a free library of over 1,500 font families that you can use in web applications. Next.js provides built-in support for Google Fonts through t... HTML HTML (HyperText Markup Language) is the standard markup language for documents designed to be displayed in a web browser. JSON in VibeReference JSON (JavaScript Object Notation) is a lightweight data-interchange format that's easy for humans to read and write and easy for machines to parse and genera... Lucide Icons Lucide is already integrated with your VibeReference project. The icons are available through the `lucide-react` package. Next.js Next.js is a React framework that enables server-side rendering, static site generation, and other advanced features for production-ready React applications. {/* 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
Related Topics in Frontend