w3tweaks.com · CSS Tutorial

CSS Dark Mode

prefers-color-scheme · light-dark() · three-state toggle · FOUC fix

Demo theme
System preference
Tab 1

Live Dark Mode Preview

Toggle the theme above to see all components switch. Notice: cards, inputs, buttons, images, and links all adapt. Use the toggle above to switch between System / Light / Dark.

⚡ Featured

Card Component

Card background, text, and border all come from CSS custom properties. One set of variables, two themes.

Form Inputs

Native form elements need color-scheme or explicit background/color to look correct in dark mode.

Image Dimming

Photos should be slightly dimmed in dark mode to reduce eye strain. The image below uses filter: brightness(0.9) automatically.

📸 Photo placeholder
Auto-dimmed in dark mode via CSS selector

Typography & Links

Body copy adapts automatically. This link uses a consistent accent color in both modes. Muted text uses a lighter grey in dark mode.

This is muted secondary text — darker in light mode, lighter in dark mode.

How the live demo works: The entire page uses CSS custom properties defined on :root. Clicking the toggle sets data-demo-theme="dark" on the container — the [data-demo-theme="dark"] selector overrides every token in one place. No duplicate CSS, no hunting through stylesheets.
Tab 2

light-dark() Function

The light-dark() function lets you declare both values in one line — no separate media query needed. But it requires color-scheme: light dark to work.

Old way vs light-dark()
Old — verbose, repeated
:root {
  --bg: #f8fafc;
  --text: #1e293b;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f172a;
    --text: #e2e8f0;
  }
}

Every token declared twice. Media query block grows with each new token. Hard to maintain.

New — light-dark() in one line
:root {
  color-scheme: light dark; ← required!
}

.card {
  background: light-dark(#ffffff, #1e293b);
  color: light-dark(#1e293b, #e2e8f0);
}

Both values inline. No separate media query. Easier to scan, easier to maintain.

Critical gotcha: color-scheme: light dark is REQUIRED
light-dark() reads the active color-scheme — not directly from prefers-color-scheme. Without color-scheme: light dark on the element (or an ancestor), light-dark() always returns the light value, even if the user has dark mode enabled. Always set color-scheme: light dark on :root first.
color-scheme — Also Fixes Native UI Elements
Without color-scheme
Native inputs (forced light)

Native inputs stay light even when the page is in dark mode — inconsistent, jarring UX.

With color-scheme: light dark ✓
Native inputs (follows scheme)

color-scheme: light dark tells the browser to also switch scrollbars, form inputs, and other native UI to dark mode.

Scoped color-scheme — Always-Dark Sections
Light section (follows page)
Always dark (color-scheme: dark)
/* Force a section to always be dark regardless of page theme */
.dark-sidebar {
  color-scheme: dark;
  /* all light-dark() inside here now resolve to dark values */
}
✨ New in 2026: light-dark() now supports images
The spec was updated in March 2026 — light-dark() now accepts <image> values (URLs, gradients), not just colors:
background-image: light-dark(url(logo-dark.png), url(logo-light.png));
Browser support is rolling out now — check MDN for current status.
Tab 3

Complete Implementation — Copy & Paste

The full production-ready pattern: CSS variables, system detection, a three-state toggle, localStorage persistence, and the FOUC flash fix.

Current mode: System (following OS preference)
Stored in localStorage — persists on page reload
1
CSS — Design tokens with light/dark values
:root {
  color-scheme: light dark; /* enables light-dark() + native UI theming */
  /* Light mode tokens (defaults) */
  --bg: #f8fafc;
  --surface: #ffffff;
  --text: #1e293b;
  --muted: #64748b;
  --border: rgba(0,0,0,.08);
  --accent: #8b5cf6;
}

/* Dark mode override — system preference */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #0f172a;
    --surface: #1e293b;
    --text: #e2e8f0;
    --muted: #94a3b8;
    --border: rgba(255,255,255,.08);
  }
}

/* Manual dark override (user chose dark) */
[data-theme="dark"] {
  --bg: #0f172a; --surface: #1e293b; --text: #e2e8f0;
  --muted: #94a3b8; --border: rgba(255,255,255,.08);
}

/* Image dimming in dark mode */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) img,
  [data-theme="dark"] img { filter: brightness(.9) contrast(1.05); }
}
2
HTML head — FOUC fix (inline script, before CSS)

Without this, users who chose "dark" via your toggle will see a flash of light theme while JavaScript loads. This tiny inline script sets the attribute synchronously before the browser paints.

<!-- In <head>, BEFORE your CSS link -->
<script>
  (function() {
    const saved = localStorage.getItem('theme');
    if (saved === 'dark' || saved === 'light') {
      document.documentElement.dataset.theme = saved;
    }
  })();
</script>
3
JavaScript — Three-state toggle with persistence
function setTheme(theme) {
  const root = document.documentElement;
  if (theme === 'system') {
    delete root.dataset.theme; // remove override — OS wins
    localStorage.removeItem('theme');
  } else {
    root.dataset.theme = theme;
    localStorage.setItem('theme', theme);
  }
}

// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      // only react if user hasn't set a preference
      updateToggleUI(e.matches ? 'dark' : 'light');
    }
  });
4
Toggle button HTML — accessible
<button
  id="theme-toggle"
  aria-label="Switch to dark mode"
  aria-pressed="false"
  onclick="cycleTheme()"
>
  🌙 Dark mode
</button>

Update aria-label and aria-pressed via JavaScript to reflect the current mode. Screen readers announce the current state.

Read the tutorial