React
Dark mode with next-themes
ThemeProvider with attribute="class", a dark variant for Tailwind 4 and a toggle that reads resolvedTheme: without these the theme never changes.
Key points
- `attribute="class"` writes `.dark` on the html element; Tailwind 4 needs `@custom-variant dark` to read that class.
- Read `resolvedTheme`, not `theme`: with `defaultTheme="system"` the value can be “system” and the toggle breaks.
- `suppressHydrationWarning` on the html element avoids the server/client mismatch.
Short answer: wrap the app in ThemeProvider with attribute="class", defaultTheme="system" and enableSystem, add @custom-variant dark to the Tailwind 4 CSS, and read resolvedTheme in the toggle. next-themes applies the class before the first paint.
The provider, once
The provider sits above everything that reads the theme. With the App Router, the file holding it is a client component called from the server layout. disableTransitionOnChange switches CSS transitions off during the change; otherwise the background animates while the page flips and the result looks like a glitch.
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
suppressHydrationWarning is there because the server cannot know which theme the user picked: the class on the html element changes before React hydrates, and without the attribute the console reports a mismatch. It is not a general permission: it applies to that element only.
Tailwind 4 does not read .dark on its own
In Tailwind 4 the dark: variant follows prefers-color-scheme by default. With next-themes the choice is a class, so the variant has to be redefined in the global CSS. Without that line the button changes the class on the html element and no dark: utility ever fires.
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
The token values for the two themes live in :root and .dark, mapped in @theme inline. The full procedure is in how to theme shadcn/ui.
The toggle that does not jump
The saved theme lives in localStorage, which the server cannot see. The first client render therefore has to show something neutral, and only after hydration can it read the real value. resolvedTheme turns “system” into the actual preference; theme may hold “system” and break a comparison against “dark”.
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return ;
}
const isDark = resolvedTheme === "dark";
return (
setTheme(isDark ? "light" : "dark")}
>
{isDark ? "Chiaro" : "Scuro"}
);
}
Why there is no flash
next-themes injects an inline script into the
that readslocalStorage and applies the class before the browser paints the body. It is the same principle described in dark mode without a flash, with one difference: the library generates the script, so you do not copy it by hand. If the flash still appears, the provider sits too low in the tree or suppressHydrationWarning is missing.
A named limitation
The theme changes only what reads the tokens. A component with fixed colours, bg-white or text-zinc-900 for instance, stays light inside .dark: next-themes never rewrites classes. The catalogue, measured on 1 November 2026, has a single block with “dark” in the id, so most examples need checking by hand in the dark theme.
Where to continue
In the catalogue you find the shadcn library, the shadcn icons record and a block with a dark variant. Beyond that, read what shadcn/ui is and dark mode without a second design system; the strategy comparison is in free versus premium shadcn themes.
Frequently asked questions
Why does dark: not work with next-themes?
Because in Tailwind 4 the variant follows prefers-color-scheme. With attribute="class" you need @custom-variant dark (&:is(.dark *)) in the global CSS, otherwise the .dark class fires no utility.
Why does the theme flash on load?
The provider sits too low in the tree, or suppressHydrationWarning is missing on the html element. next-themes belongs in the root layout and must write the class before the first paint.
Should I use theme or resolvedTheme?
resolvedTheme, when you need to know which theme is actually active. theme may hold "system"; resolvedTheme returns "light" or "dark" from the system preference.
Does the toggle need a mounted component?
Yes, when the toggle shows an icon or text that depends on the theme. The server cannot read localStorage, so the first render must be neutral and the real value arrives after hydration.
Does dark mode change shadcn components too?
Yes, when they read semantic tokens. A component with fixed classes such as bg-white stays light: the theme never rewrites classes, it only changes variable values.