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
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
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).
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