Implementing Dark Mode in Next.js with Tailwind CSS
August 19, 2024 at 09:00 AM
By IPSLA
Next.js
Tailwind CSS
Dark Mode
Frontend
UI/UX
React Context
Dark mode has become a popular and often expected feature in modern web applications. It offers users a more comfortable viewing experience in low-light environments, can reduce eye strain, and for some, is simply a preferred aesthetic. Implementing dark mode in a Next.js application with Tailwind CSS can be achieved quite elegantly, typically using Tailwind's built-in dark mode variant and a client-side mechanism to toggle and persist the theme choice, ensuring a seamless user experience across sessions.
**Core Concepts for Dark Mode with Tailwind CSS:**
1. **Tailwind CSS Dark Mode Variant:** Tailwind CSS provides a built-in `dark:` utility variant. When dark mode is enabled, you can apply different utility classes. For example, `bg-white dark:bg-slate-900` will set a white background in light mode and a dark slate background when dark mode is active. This allows for granular control over styling for both themes directly in your markup.
2. **Strategy for Enabling Dark Mode:** Tailwind CSS supports two main strategies for activating its `dark:` variants, configured in your `tailwind.config.js` file via the `darkMode` option:
* **`class` strategy (Most Flexible and Recommended):** Dark mode is activated by adding a specific class (by default, `dark`) to a parent HTML element (usually the root `<html>` or `<body>` tag). You then use JavaScript to add or remove this class, allowing the user to toggle the theme. This strategy gives you full control over when dark mode is applied and is ideal for user-toggleable themes.
* **`media` strategy:** Dark mode is automatically enabled based on the user's operating system preference detected via the CSS `prefers-color-scheme: dark` media query. This requires no JavaScript for toggling but offers less direct user control within the app itself (the app just follows the OS). It's simpler but less flexible if you want an in-app toggle.
3. **Persistence of Theme Choice:** Users typically expect their theme preference (light or dark) to be remembered across sessions and page navigations. This is commonly handled using the browser's `localStorage` API to store the selected theme.
4. **Avoiding Flash of Incorrect Theme (FOIT):** When using client-side toggling and localStorage, there can be a brief moment where the default theme (e.g., light, as rendered by the server) is shown before JavaScript loads and applies the user's preferred dark theme from localStorage. This "flash" can be mitigated. A common approach is to include a small, blocking inline script in the `<head>` of your HTML document (`_document.tsx` in Pages Router or directly in `layout.tsx` for App Router) that reads from `localStorage` and applies the `dark` class to the `<html>` element *before* the rest of the page content or React hydrates.
**Implementation Steps (Using `class` Strategy and React Context for State Management):**
**Step 1: Configure Tailwind CSS for Class-Based Dark Mode**
In your `tailwind.config.js` (or `tailwind.config.ts`), set the `darkMode` option to `'class'`:
\`\`\`javascript
// tailwind.config.js
module.exports = {
darkMode: 'class', // This enables the class-based strategy
content: [
'./src/app/**/*.{js,ts,jsx,tsx}',
'./src/components/**/*.{js,ts,jsx,tsx}',
// Add other paths where Tailwind classes are used
],
theme: {
extend: {
// Your theme extensions
},
},
plugins: [],
};
\`\`\`
**Step 2: Create a Theme Context and Provider**
This React Context will manage the current theme state (`'light'` or `'dark'`) and provide a function to toggle it. It will also handle reading from and writing to `localStorage`.
\`\`\`tsx
// src/context/theme-context.tsx (Example)
'use client'; // This provider will manage client-side state and effects
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
type Theme = 'light' | 'dark';
type ThemeContextType = {
theme: Theme;
setTheme: (theme: Theme) => void; // Allow setting specific theme
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Helper function to get initial theme, can be run by a script in <head> too
const getInitialTheme = (): Theme => {
if (typeof window !== 'undefined') {
const storedPrefs = window.localStorage.getItem('theme');
if (typeof storedPrefs === 'string' && (storedPrefs === 'light' || storedPrefs === 'dark')) {
return storedPrefs;
}
const userMedia = window.matchMedia('(prefers-color-scheme: dark)');
if (userMedia.matches) {
return 'dark';
}
}
return 'light'; // Default theme
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
// Effect to apply the class to <html> and save to localStorage
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove(theme === 'light' ? 'dark' : 'light');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
}, []);
// To prevent FOUC further, an inline script in RootLayout's <head> or _document.js
// is the most robust way to set the class on <html> before any rendering.
// Example inline script (needs to be adapted for your specific setup):
// <script dangerouslySetInnerHTML={{ __html: \`
// (function() {
// function getInitialTheme() { /* ... same logic as above ... */ }
// document.documentElement.classList.add(getInitialTheme());
// })();
// \` }} />
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
\`\`\`
**Step 3: Wrap Your Application with the ThemeProvider and Add Anti-Flicker Script**
In your root layout file (`src/app/layout.tsx` for App Router):
\`\`\`tsx
// src/app/layout.tsx
import { ThemeProvider } from '@/context/theme-context'; // Adjust path as needed
import './globals.css'; // Your global styles
// Script to prevent FOUC (Flash Of Unstyled Content or Incorrect Theme)
const NoFlashScript = () => {
// This IIFE script should be very minimal and match the logic in ThemeProvider's getInitialTheme
const script = \`
(function() {
const theme = (function() {
const storedPrefs = window.localStorage.getItem('theme');
if (typeof storedPrefs === 'string') return storedPrefs;
const userMedia = window.matchMedia('(prefers-color-scheme: dark)');
if (userMedia.matches) return 'dark';
return 'light';
})();
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
\`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning> {/* suppressHydrationWarning helps with theme mismatches */}
<head>
<NoFlashScript />
</head>
<body>
<ThemeProvider>
{/* Your app structure: Header, main content, footer */}
{children}
</ThemeProvider>
</body>
</html>
);
}
\`\`\`
**Step 4: Create a Theme Toggle Button Component**
This component will use the `useTheme` hook to toggle the theme.
\`\`\`tsx
// src/components/theme-toggle-button.tsx (Example)
'use client';
import { useTheme } from '@/context/theme-context'; // Adjust path
import { Button } from '@/components/ui/button'; // Assuming you use ShadCN UI Button
import { Moon, Sun } from 'lucide-react'; // Icons for toggle
import React from 'react'; // Import React for useEffect and useState
export function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
// Ensure component only renders on client to avoid hydration issues with theme state
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (!mounted) {
// Or return a placeholder/skeleton, or null
return <Button variant="ghost" size="icon" disabled className="h-9 w-9 opacity-0" />;
}
return (
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label="Toggle theme">
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</Button>
);
}
\`\`\`
You can then place this `<ThemeToggleButton />` in your header or another appropriate location.
**Step 5: Style Your Components Using `dark:` Variants**
Now, in any of your components, you can use Tailwind's `dark:` prefix to apply styles specifically for dark mode.
\`\`\`tsx
<div className="bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-4 rounded-lg">
<h1 className="text-xl font-semibold text-blue-600 dark:text-blue-400">Hello World</h1>
<p>This component adapts its background and text colors to the current theme!</p>
</div>
\`\`\`
**Using CSS Variables for Theming (Common with ShadCN UI):**
If you're using a component system like ShadCN UI, it often relies on CSS custom properties (variables) for theming, defined in your `globals.css` for both light and dark themes. The `class` strategy for Tailwind's dark mode works perfectly with this. When the `.dark` class is on the `<html>` element, the CSS variables defined within the `.dark { ... }` scope in your `globals.css` take precedence.
Example from a typical `globals.css` setup by ShadCN:
\`\`\`css
/* src/app/globals.css */
@layer base {
:root { /* Light mode variables */
--background: 0 0% 100%; /* white */
--foreground: 222.2 84% 4.9%; /* dark gray/black */
/* ... other light theme color variables ... */
}
.dark { /* Dark mode variables, applied when .dark class is present on html */
--background: 222.2 84% 4.9%; /* dark gray/black */
--foreground: 210 40% 98%; /* nearly white */
/* ... other dark theme color variables ... */
}
}
@layer base {
body {
@apply bg-background text-foreground; /* Uses CSS variables */
/* ... other base body styles ... */
}
}
\`\`\`
With this setup, components styled with Tailwind classes that map to these CSS variables (e.g., `bg-background`, `text-foreground`, `border-border`) will automatically adapt when the dark class is toggled by your `ThemeProvider`. The inline script ensures the `.dark` class is present on initial load if preferred, minimizing flicker.
By combining Tailwind's dark mode variant with React Context for state management, `localStorage` for persistence, and an inline script to prevent theme flashing, you can create a robust and user-friendly dark mode experience in your Next.js application. Remember to test thoroughly.