How to Add Dark Mode to a Website (CSS, Tailwind, Next.js)

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.

Which approach fits your stack
StackFollows the OSManual toggleKey piece
Plain CSSYesNo@media (prefers-color-scheme: dark)
CSS + small scriptYesYesdata-theme on html + inline head script
Tailwind CSS v4Yes, by defaultWith @custom-variantdark: utilities
Next.js App RouterYesYesnext-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.

Theme preview

Switch between Light, Dark and System to see a sample layout change, including its form controls.

1. Pure CSS that follows the system

Variables swapped by prefers-color-scheme

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>

The same idea with light-dark()

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; }

2. A Light, Dark and System toggle with no flash

Inline script at the top of the head

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>

CSS that respects both the attribute and the system

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); }

The toggle itself

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;
});

3. Tailwind CSS v4

dark: utilities with a class toggle

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>

4. Next.js App Router with next-themes

Root layout and a client provider

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>
  );
}

Toggle that waits until it has mounted

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>
  );
}

Build the palette with variables first

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.

Making the toggle accessible

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.

Server rendering and hydration

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.

How it works

  1. Pick Light, Dark or System in the preview. System follows your operating system and updates live if you change it.
  2. Notice the form field and scrollbar also switch. That is the color-scheme property at work.
  3. Choose your stack from the examples below and copy the code. The no-flash script goes inline in the head.
  4. Test by reloading in each mode, and once with your operating system set to dark and nothing saved.

Tips

Frequently asked questions

How do I stop the white flash when the page loads in dark mode?

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.

How does Tailwind dark mode work?

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.

How do I add dark mode in Next.js?

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.

What does color-scheme do?

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.

Should dark mode follow the system or have a toggle?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap