Dark mode

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: A light/dark color theme that users can pick, with the option to follow their system preference. Modern web users expect it; it’s not a “nice to have” anymore.


In plain English

Dark mode is when a site uses a dark background with light text, instead of the traditional light background with dark text. It’s not just an aesthetic preference — many users prefer dark mode for:

  • Reduced eye strain in low light. Bright white screens at night feel harsh.
  • Battery savings on OLED screens. Black pixels are literally off.
  • Personal preference / accessibility. Some users with light sensitivity or migraine triggers genuinely need dark UIs.

In 2026, users expect dark mode on every serious app. Operating systems (Windows, macOS, iOS, Android) have system-wide settings; major apps follow that preference. A site without dark mode feels dated and is sometimes unpleasant to use.

The good news: implementing dark mode in a Tailwind + Next.js project is a 30-minute task. The hard part isn’t the toggle — it’s picking colors that work well in both modes (which is design work).


Why it matters

  • User expectation. Common feature; its absence is noticeable.
  • Eye comfort and accessibility. A real benefit for some users.
  • Battery life. Measurable on OLED screens (most phones, increasingly laptops).
  • It’s cheap to implement. Disproportionate user satisfaction per hour of effort.
  • Tailwind makes it ~30 minutes. Not weeks of work.

The three flavors of “dark mode”

When designing dark mode, you choose between three approaches:

1. System-only

Site automatically follows the OS preference. No user toggle. Simplest.

@media (prefers-color-scheme: dark) {
  body { background: black; color: white; }
}

Tailwind v3 with darkMode: 'media' does this. Good for content sites; doesn’t give users override.

A dark class on <html> activates dark styles. JavaScript controls when the class is on or off. Allows a user toggle + system-preference detection + persistence.

/* tailwind.config.ts: darkMode: 'class' */
.bg-white { background: white; }
.dark .bg-white { background: black; }

This is what next-themes enables, and it’s the modern default.

3. User-only

No system detection — purely a toggle. Rare; usually you want at least to default to system preference.


The implementation in Tailwind + Next.js

Quick version (full walkthrough in How-to: Set up dark mode):

npm install next-themes
// tailwind.config.ts
export default {
  darkMode: "class",
  // ...
};
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}
// app/layout.tsx
<html lang="en" suppressHydrationWarning>
  <body>
    <Providers>{children}</Providers>
  </body>
</html>
// Anywhere in your UI — use dark: prefixes
<div className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  Adapts to dark mode
</div>

That’s the whole mechanism. next-themes handles persistence, system detection, the FOUC fix, and the toggle UI helpers.


Designing for dark mode — it’s not just inverting colors

The cardinal mistake is “dark mode = invert all colors.” That produces ugly results because:

  • Pure black + pure white is too harsh. Use very dark gray (#0a0a0a, #121212, slate-900) and off-white (slate-100, slate-50) instead.
  • Saturated colors look different in dark mode. A vibrant blue button on white may need to be a softer shade on dark.
  • Shadows don’t work the same way. Drop shadows assume light from above on a light surface. In dark mode, prefer subtle highlights or borders.
  • Images need consideration. A photo with bright sky and dark foreground reads differently against dark vs light.

A common pattern: define semantic color tokens that map to different actual colors in light vs dark.

// tailwind.config.ts
theme: {
  extend: {
    colors: {
      surface: {
        DEFAULT: "rgb(255 255 255)",      // light: white
        dark: "rgb(15 23 42)",            // dark: slate-900
      },
      ink: {
        DEFAULT: "rgb(15 23 42)",         // light text
        dark: "rgb(241 245 249)",         // dark mode text
      }
    }
  }
}

Then use bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark. Theme changes happen in one place.

Or use CSS custom properties (what shadcn/ui does):

:root {
  --background: 0 0% 100%;
  --foreground: 222 47% 11%;
}
.dark {
  --background: 222 47% 11%;
  --foreground: 0 0% 98%;
}

The HSL triplets are converted to actual colors by Tailwind. Switching the .dark class flips everything that references the tokens.


Contrast in dark mode

Same WCAG rules apply: text needs 4.5:1 contrast against its background (3:1 for large text). Dark mode introduces new failure modes:

  • “Dark gray text on dark background” — looks intentional but fails contrast badly
  • “Faded muted text” — light gray (#888) on dark background may pass on some monitors and fail on others

Test dark mode with the same WebAIM contrast checker you use for light mode. Don’t assume.


The FOUC problem

If you toggle to dark mode and reload, there’s a moment of “flash of unstyled content” — the page renders in default (light) before JavaScript applies the saved preference. Jarring.

next-themes solves this with a tiny inline <script> that runs before any rendering, checking localStorage and applying the right class. You get correct theme on first paint.

Manual implementation:

<script>
  (function() {
    const stored = localStorage.getItem('theme');
    const system = window.matchMedia('(prefers-color-scheme: dark)').matches;
    if (stored === 'dark' || (!stored && system)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>

Place this in <head> BEFORE any stylesheets or other scripts.


A toggle component

"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => setMounted(true), []);
  if (!mounted) return <button aria-label="Toggle theme" className="w-9 h-9" />;
 
  const isDark = theme === "dark";
  return (
    <button
      onClick={() => setTheme(isDark ? "light" : "dark")}
      aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
      className="w-9 h-9 inline-flex items-center justify-center rounded
                 hover:bg-slate-100 dark:hover:bg-slate-800"
    >
      {isDark ? "☀️" : "🌙"}
    </button>
  );
}

Notes:

  • The mounted check prevents hydration mismatch
  • aria-label is essential — the icon alone isn’t accessible
  • Same width/height for both states avoids layout shift

For a three-state toggle (light / dark / system), use a Select or radio group.


Images in dark mode

Three approaches for images that need to look different:

CSS filter (cheap but crude)

.dark img { filter: brightness(0.85); }

Quick but looks bad on photos.

Separate dark-mode image

<picture>
  <source srcset="hero-dark.jpg" media="(prefers-color-scheme: dark)">
  <img src="hero-light.jpg" alt="...">
</picture>

Best result; more work.

Inline SVG with currentColor

For icons, use SVGs with fill="currentColor" — they automatically take the surrounding text color.


Common gotchas

  • dark: prefix written incorrectly. Tailwind’s class is literally dark: (with the colon). Not darkmode: or :dark.

  • darkMode config wrong value. 'class' is the modern default. 'media' follows system preference only. 'selector: ".my-dark"' for custom selectors. Pick one explicitly.

  • Tailwind v4 changes. v4 uses different config syntax (CSS-first). Tutorials may show v3 conventions; verify against your version.

  • Forgetting suppressHydrationWarning on <html> when using next-themes. The theme provider modifies the class on the client immediately; without the suppression, React throws hydration warnings.

  • Hardcoded colors. A <div style={{ background: "white" }}> doesn’t adapt. Use Tailwind utility classes or CSS variables.

  • Box shadows that disappear in dark mode. Black shadows on dark backgrounds are invisible. Use subtle borders or different shadows for dark.

  • Brand colors that look ugly in dark mode. A vibrant brand red may need to be slightly muted on dark backgrounds. Define two variants.

  • Forgetting to test forms / errors in both modes. Error states, warning yellows, success greens all need treatment in both modes.

  • Charts and graphs. Recharts, Chart.js, etc. need theme-aware color schemes. Pass current theme as a prop.

  • Embedded content (YouTube videos, iframes, third-party widgets) won’t follow your theme. Live with it or pick a single theme for them.

  • PDFs and downloads. Don’t auto-style PDFs differently in dark mode. Provide a “Print version” if needed.

  • System preference detection on cross-tab sync. next-themes syncs across tabs automatically. Custom implementations need to listen to storage events.

  • “I changed my brand color and dark mode broke.” Maintain both palettes side by side. CSS variables / semantic tokens make this easier.

  • prefers-color-scheme doesn’t update mid-session in all browsers. Some browsers only check at page load. Refresh helps.

  • Trying to detect “is it nighttime.” Don’t. Respect the user’s setting; don’t override based on time of day.


See also

Sources