Creating a dynamic dark mode with Next.js, React, and Tailwind 4
One of Tailwind 4's many great features is native dark mode support, powered by default by the prefers-color-scheme CSS media query. But what if you want to give your users the ability to toggle between dark and light mode without changing their system settings?
Here's how to achieve it with Tailwind 4 and Next.js 15. Once complete, our project will:
- Set the initial theme mode based on the user's system preference
- Allow the user to override the default
- Support standard Tailwind variant prefixes, for example
light:color-blueanddark:color-red
Let's get going! First, we'll change our Tailwind configuration to power dark mode from a class selector:
{
"darkMode": "class"
}
This tells Tailwind that we'll be providing custom theme classes. In our primary Tailwind CSS entrypoint file, we'll define those classes:
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
Now, when a parent element has the dark class, all children with dark: variant classes will activate their dark mode configurations, and vice versa for light.
Allowing user control
Now let's set up a toggle button that will override the system's default state.
import { useContext } from "react";
import { MdOutlineModeNight } from "react-icons/md";
import { AiOutlineSun } from "react-icons/ai";
const themeContext = useContext(ThemeContext);
<button
type="button"
onClick={() =>
themeContext?.setTheme((theme) =>
theme === "dark" ? "light" : "dark",
)
}
className="light-switch px-2 py-2 cursor-pointer transition-all hover:opacity-60"
>
<span className="sr-only">
Change to {themeContext?.theme === "dark" ? "light" : "dark"}{" "}
theme
</span>
<MdOutlineModeNight
size={20}
className="hidden light:block dark:text-white"
/>
<AiOutlineSun
size={20}
className="hidden dark:block light:text-charcoal"
/>
</button>
Our themeContext references a simple useState() keeping track of the current user selection:
import React, { createContext } from 'react';
export type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
}
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
Thanks to the relatively new window.matchMedia() API, we can set the initial state to that of the user's system settings. This means that when the CSS media query prefers-color-scheme: dark evaluates to true, our initial state will match.
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
setTheme(darkModePreference.matches ? 'dark' : 'light')
}, []);
For this to work, the component managing your state needs to be high enough in your component tree that child components all inherit the dark/light mode class. In the Next.js app running this app, I created a <LayoutClient /> client component to serve this purpose as well as provide the ThemContext to all children. <LayoutClient /> is rendered inside the server component layout.tsx. This allows our layout to be server-side rendered unless client-side state is needed.
"use client";
import HeaderAndNav from "./HeaderAndNav";
import { useEffect, useState } from "react";
import { Theme, ThemeContext } from "../context/ThemeContext";
export default function LayoutClient({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
setTheme(darkModePreference.matches ? 'dark' : 'light')
}, []);
return (
<body
className={`container px-4 mx-auto antialiased bg-sand print:bg-white dark:bg-charcoal print:px-0 ${theme}`}
>
<ThemeContext.Provider value={{theme, setTheme}}>
<HeaderAndNav className="mt-0.5 pt-3 border-b-2 border-accent-color"/>
<main className="pb-10 print:pb-0">
{children}
</main>
</ThemeContext.Provider>
</body>
);
}
Permalink