Best Practices

AI Rules for Dark Mode Implementation

AI inverts colors and calls it dark mode. Rules for CSS custom properties, system preference detection, persistent theme choice, accessible contrast ratios, and semantic color tokens.

7 min read·February 11, 2025

filter: invert(1) turns photos into negatives and gray-on-gray text is invisible

CSS custom properties, system preference, flash-free persistence, contrast verification, semantic tokens

AI Thinks Dark Mode Is filter: invert(1)

AI generates dark mode with: filter: invert(1) on the body (inverts everything including images, making photos look like negatives), hardcoded color values (background: #1a1a1a scattered across 50 files instead of a single token), no system preference detection (ignores the user OS dark mode setting), no persistence (theme resets on page refresh), and no contrast verification (gray text on dark gray background — unreadable). Dark mode is not color inversion. It is a complete alternate color system.

Modern dark mode is: token-based (CSS custom properties define colors once, themes swap the values), system-aware (prefers-color-scheme detects OS preference automatically), persistent (user choice saved in localStorage, applied before paint), contrast-verified (WCAG AA minimum 4.5:1 ratio in both themes), and flash-free (no white flash on load before dark styles apply). AI generates none of these.

These rules cover: CSS custom properties for theming, system preference detection, theme persistence without flash, accessible contrast in both modes, and semantic color token architecture.

Rule 1: CSS Custom Properties for Theme Tokens

The rule: 'Define all colors as CSS custom properties on :root for light mode and [data-theme="dark"] for dark mode: :root { --color-bg: #ffffff; --color-text: #1a1a1a; --color-primary: #2563eb; } [data-theme="dark"] { --color-bg: #0f172a; --color-text: #e2e8f0; --color-primary: #60a5fa; }. Use the variables everywhere: background-color: var(--color-bg). Changing the theme changes every color in the app with zero CSS specificity conflicts.'

For semantic naming: 'Name tokens by purpose, not by color value: --color-bg (not --color-white), --color-text (not --color-black), --color-primary (not --color-blue), --color-error (not --color-red). Semantic names: work in both themes (--color-bg is white in light mode, dark blue in dark mode), communicate intent (--color-error is always the error color regardless of its actual hue), and survive redesigns (change the value of --color-primary without renaming anything).'

AI generates: color: #333333 hardcoded in 200 places. To add dark mode: find and replace 200 color values with conditional logic. With CSS custom properties: change 20 token values in one rule. 200 elements update automatically. One definition point, zero find-and-replace.

  • :root for light mode, [data-theme='dark'] for dark mode — one selector swap
  • Semantic token names: --color-bg, --color-text, --color-primary, --color-error
  • var(--color-bg) everywhere — zero hardcoded color values in component CSS
  • Theme change = 20 token values updated — 200 elements respond automatically
  • Tailwind CSS 4: theme colors in @theme directive, dark: variant for dark mode
💡 20 Tokens, 200 Elements

With CSS custom properties: change 20 token values in one selector ([data-theme='dark']). 200 elements using var(--color-bg) update automatically. Without tokens: find and replace 200 hardcoded color values across 50 files. One definition point vs everywhere.

Rule 2: System Preference Detection

The rule: 'Detect the user OS dark mode setting with the prefers-color-scheme media query: @media (prefers-color-scheme: dark) { :root { --color-bg: #0f172a; } }. In JavaScript: window.matchMedia('(prefers-color-scheme: dark)').matches. Listen for changes: mediaQuery.addEventListener('change', (e) => { setTheme(e.matches ? 'dark' : 'light'); }). Respect the system preference as the default — then allow the user to override.'

For the three-state toggle: 'Theme options should be: System (follows OS setting), Light (always light), Dark (always dark). Default to System. Store the user choice (not the resolved theme). When the user selects System: apply the current OS preference and listen for changes. When they select Light or Dark: apply that choice regardless of OS setting. The toggle label should show the current selection, not the current appearance.'

AI generates: a toggle button that switches between light and dark with no system detection. Users who have set their OS to dark mode see a blinding white page until they find and click the toggle. System preference detection: the page loads in the correct mode automatically. The toggle is for users who want to override — not the default path.

Rule 3: Theme Persistence Without Flash

The rule: 'Save the user theme choice to localStorage: localStorage.setItem("theme", "dark"). On page load, apply the saved theme before the page renders. The critical technique: inject a blocking script in the <head> that reads localStorage and sets data-theme before any CSS or content renders. This prevents the flash of wrong theme (FOWT) — where the page briefly shows light mode before JavaScript loads and switches to dark.'

For the blocking script: 'In the HTML <head>, before any stylesheet: <script>const t=localStorage.getItem("theme");if(t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.dataset.theme="dark"}</script>. This script: runs before CSS parsing (blocking), reads localStorage (synchronous), and sets the data-theme attribute (immediate). The first paint is already in the correct theme. Zero flash.'

AI generates: useEffect(() => { setTheme(localStorage.getItem('theme')); }, []) — runs after React hydration. The page renders in light mode (default), then flips to dark mode 200ms later. The user sees a white flash on every page load. A blocking head script eliminates this entirely — the first pixel painted is in the correct theme.

  • localStorage for theme persistence — survives page refresh and browser restart
  • Blocking <script> in <head> — reads and applies theme before first paint
  • No flash of wrong theme (FOWT) — first paint is always in the correct mode
  • Three-state storage: 'light', 'dark', 'system' — store choice, not resolved theme
  • Next.js: use cookies or blocking script in layout.tsx — server-render correct theme
⚠️ 200ms White Flash

useEffect reads localStorage after React hydration — the page renders light, then flips to dark 200ms later. A blocking <script> in <head> reads localStorage before CSS parsing. First pixel painted is in the correct theme. Zero flash.

Rule 4: Accessible Contrast in Both Themes

The rule: 'Both light and dark themes must meet WCAG AA contrast ratios: 4.5:1 for normal text, 3:1 for large text (18px+ or 14px+ bold). Common dark mode failures: gray text on dark gray background (2:1 ratio — unreadable), dim primary colors (blue #60a5fa on dark background may need lightening), and pure white text on pure black (technically high contrast but causes halation — text appears to bleed/glow for some users).'

For dark mode specific considerations: 'Avoid pure black (#000000) backgrounds — use dark gray (#0f172a, #1e293b) for reduced halation and perceived depth. Reduce white text brightness slightly (#e2e8f0 instead of #ffffff) for comfortable reading. Increase the saturation of accent colors (they appear dimmer on dark backgrounds). Use elevated surfaces (#1e293b) over shadows for depth — shadows are invisible on dark backgrounds.'

AI generates: dark mode with #333 text on #222 background (1.4:1 ratio — essentially invisible) or #fff text on #000 (21:1 ratio — technically perfect but causes halation). Neither is comfortable to read. Proper dark mode tokens: carefully chosen color pairs that are both accessible (4.5:1+) and comfortable (avoiding extremes).

ℹ️ #333 on #222 = Invisible

Dark mode with #333 text on #222 background has a 1.4:1 contrast ratio — essentially invisible. WCAG AA requires 4.5:1. Pure white on pure black (21:1) causes halation — text appears to glow. The sweet spot: #e2e8f0 on #0f172a (14:1, comfortable).

Rule 5: Semantic Color Token Architecture

The rule: 'Build a three-tier color token system: Tier 1 (primitive): raw color values — --blue-500: #3b82f6. Tier 2 (semantic): purpose-based tokens referencing primitives — --color-primary: var(--blue-500). Tier 3 (component): component-specific tokens referencing semantic — --button-bg: var(--color-primary). Theme switching changes Tier 2 mappings: --color-primary points to --blue-500 in light mode and --blue-400 in dark mode. Tier 1 and Tier 3 remain unchanged.'

For scale definition: 'Define a complete scale for each semantic category: --color-bg (page background), --color-bg-secondary (card background), --color-bg-tertiary (input background), --color-text (primary text), --color-text-secondary (secondary text), --color-text-muted (disabled/placeholder text), --color-border (default borders), --color-border-strong (emphasized borders). Both themes define all tokens — no gaps, no missing states.'

AI generates: 5 color variables for light mode, 3 for dark mode (missing secondary text, borders, and muted states). Dark mode components use hardcoded fallback values or inherit wrong colors. A complete token system: both themes define the same set of tokens. Every component uses tokens. No hardcoded colors, no missing states, no theme-specific bugs.

Complete Dark Mode Rules Template

Consolidated rules for dark mode implementation.

  • CSS custom properties: :root for light, [data-theme='dark'] for dark — one swap
  • Semantic token names: --color-bg, --color-text — never --color-white or --color-black
  • prefers-color-scheme detection as default — three-state toggle: system, light, dark
  • Blocking head script for persistence — no flash of wrong theme (FOWT)
  • WCAG AA contrast in both themes: 4.5:1 normal text, 3:1 large text
  • No pure black backgrounds — use dark gray (#0f172a) for reduced halation
  • Three-tier tokens: primitive > semantic > component — theme changes at semantic layer
  • Complete token set in both themes — no missing states, no hardcoded fallbacks
AI Rules for Dark Mode Implementation — RuleSync Blog