The simplest dark mode is pure CSS: declare color-scheme: light dark, keep colours in custom properties, and swap them inside @media (prefers-color-scheme: dark). For a manual toggle, set a class or data attribute on the html element, save the choice in localStorage, and apply it with a tiny inline script in the head so the wrong theme never flashes.
| Stack | Follows the OS | Manual toggle | Key piece |
|---|---|---|---|
| Plain CSS | Yes | No | @media (prefers-color-scheme: dark) |
| CSS + small script | Yes | Yes | data-theme on html + inline head script |
| Tailwind CSS v4 | Yes, by default | With @custom-variant | dark: utilities |
| Next.js App Router | Yes | Yes | next-themes ThemeProvider |
Updated
Dark mode has three moving parts: colours that can switch, a way to decide which set to use, and timing so the page paints correctly the first time. The preview below lets you flip between light, dark and system on a sample layout, then each section gives the exact code for plain CSS, Tailwind and Next.js.
Switch between Light, Dark and System to see a sample layout change, including its form controls.
The result frame follows your device setting. Change your system to dark and it updates without a reload.
<style>
:root { color-scheme: light dark; --bg: #ffffff; --text: #1a1a1a; --card: #f2f4f7; --accent: #1d4ed8; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121417; --text: #e8eaed; --card: #1e2227; --accent: #8ab4ff; }
}
body { background: var(--bg); color: var(--text); }
.card { background: var(--card); padding: 14px; border-radius: 12px; }
a { color: var(--accent); }
</style>
<div class="card"><strong>Order update</strong><p>Your parcel ships today. <a href="#">Track it</a></p><input placeholder="Form controls switch too"></div>One declaration per colour. It needs color-scheme on the root, and MDN lists it as Baseline 2024.
:root { color-scheme: light dark; }
body {
background: light-dark(#ffffff, #121417);
color: light-dark(#1a1a1a, #e8eaed);
}
/* Forcing a theme from a toggle: */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }Runs before the body paints. With nothing saved it does nothing, so the media query keeps following the system.
<head>
<meta name="color-scheme" content="light dark">
<script>
try {
const t = localStorage.getItem("theme");
if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
<link rel="stylesheet" href="styles.css">
</head>The attribute wins when present. Without it, the media query decides.
:root { --bg: #fff; --text: #1a1a1a; color-scheme: light; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #121417; --text: #e8eaed; color-scheme: dark; }
}
:root[data-theme="dark"] { --bg: #121417; --text: #e8eaed; color-scheme: dark; }
body { background: var(--bg); color: var(--text); }A select with three options. Choosing System removes the saved value.
// <label>Theme <select id="theme"><option value="system">System</option>
// <option value="light">Light</option><option value="dark">Dark</option></select></label>
const select = document.querySelector("#theme");
const root = document.documentElement;
select.value = root.dataset.theme || "system";
select.addEventListener("change", () => {
const choice = select.value;
try {
if (choice === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", choice);
} catch (e) {}
if (choice === "system") delete root.dataset.theme;
else root.dataset.theme = choice;
});Without the @custom-variant line, dark: follows prefers-color-scheme. With it, the dark class on html controls the theme.
/* app.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
<!-- markup -->
<html class="dark">
<body class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div class="rounded-xl bg-gray-100 p-4 dark:bg-gray-800">Card</div>
</body>
</html>
<script>
// in the head, before paint
try {
const t = localStorage.getItem("theme");
document.documentElement.classList.toggle("dark",
t === "dark" || (t === null && matchMedia("(prefers-color-scheme: dark)").matches));
} catch (e) {}
</script>ThemeProvider is a client component, so it lives in its own file. attribute="class" pairs it with the Tailwind dark class.
// app/providers.jsx
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }) {
return <ThemeProvider attribute="class" defaultTheme="system" enableSystem>{children}</ThemeProvider>;
}
// app/layout.jsx
import { Providers } from "./providers";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body><Providers>{children}</Providers></body>
</html>
);
}The server does not know the theme, so the control renders after mount to avoid a hydration mismatch.
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
export function ThemeSelect() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<label>
Theme{" "}
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
);
}
Name colours by job, not by look: --bg, --surface, --text, --muted, --accent, --border. Components only ever use those names. Switching themes then means redefining a handful of variables in one place.
The light-dark() function shortens this further by holding both values in one declaration. MDN lists it as Baseline 2024, so keep a fallback for older browsers if your audience needs one.
A toggle is a real button with a clear name. For a two-state switch use aria-pressed; for three choices a small group of radio buttons or a select is clearer than an icon that cycles.
Announce nothing extra. The visible change is the feedback, and the control's own state tells screen reader users what is selected.
A server cannot know a visitor's theme on the first request, so rendered HTML cannot include theme-specific text like Switch to light. Render the toggle's label only after the component mounts, or render something neutral.
That is also why next-themes asks for suppressHydrationWarning on html: its script changes an attribute there before React hydrates.
Decide the theme before the first paint. Put a small blocking script at the top of the head that reads the saved choice and the system preference and sets the class or attribute on the html element. Scripts at the end of the body or inside a framework component run too late.
In Tailwind v4, the dark: variant uses prefers-color-scheme by default, so dark:bg-gray-900 applies when the system is dark. To drive it with a toggle, add @custom-variant dark (&:where(.dark, .dark *)); to your CSS and put the dark class on the html element.
The common route is the MIT licensed next-themes package. Wrap the app in its ThemeProvider inside the root layout, add suppressHydrationWarning to the html tag, and call setTheme from useTheme in a client component. It injects the no-flash script for you.
It tells the browser which schemes your page supports, so built-in parts such as form controls, scrollbars and the page background use matching colours. Setting color-scheme: light dark on the root is also required for the light-dark() function to pick its second value.
Both. Default to the system preference, because people already chose it, and offer a toggle with Light, Dark and System so a visitor can override it for your site alone.
Related: CSS variables guide, CSS media queries, Colour contrast checker, localStorage in JavaScript, Tailwind colour palette generator.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.