CSS — Dark Mode & Theme Switching
Dark mode has evolved from a niche preference to a fundamental feature of modern interfaces. It reduces eye strain in low-light environments, saves battery life on OLED displays, and satisfies the explicit prefers-color-schemepreference that over half of desktop users enable. A well-implemented theme system doesn't just flip colors — it requires a coherent token-based architecture that keeps every component in sync across themes.
There are three primary approaches to implementing dark mode in CSS: a pure CSS media query that automatically follows the OS setting, a JavaScript toggle that writes a class or attribute to the document root, and a hybrid approach that respects system preference while allowing manual override. All three rely on the same foundation — CSS custom properties — to swap color tokens at scale.
This guide walks through each approach with production-ready code, covering token architecture, FOUC prevention, multi-theme systems, image handling, accessibility, and transition animations. Every example uses semantic naming so your color system stays maintainable as the design evolves.
| 1 | /* The three pillars of dark mode */ |
| 2 | /* 1. System preference detection */ |
| 3 | @media (prefers-color-scheme: dark) { |
| 4 | :root { --bg: #0D0D0D; --text: #E0E0E0; } |
| 5 | } |
| 6 | |
| 7 | /* 2. Class-based toggle */ |
| 8 | .dark { |
| 9 | --bg: #0D0D0D; |
| 10 | --text: #E0E0E0; |
| 11 | } |
| 12 | |
| 13 | /* 3. Data attribute (multi-theme) */ |
| 14 | [data-theme="dark"] { |
| 15 | --bg: #0D0D0D; |
| 16 | --text: #E0E0E0; |
| 17 | } |
CSS custom properties are the backbone of any theme system. Define every color, shadow, border, and spacing value as a token in :root, then override them under your theme selector. Components never reference raw hex values — they always consume tokens, making theme switching a single reassignment at the root level.
Name tokens semantically by purpose, not by color. --color-surface communicates intent; --color-white couples the token to a specific value. Semantic tokens also let you adjust contrast per theme — a color that passes AA on dark backgrounds may fail on light ones, so you swap the entire value rather than patching individual declarations.
| 1 | :root { |
| 2 | /* Surface colors */ |
| 3 | --color-bg-primary: #FFFFFF; |
| 4 | --color-bg-secondary: #F5F5F5; |
| 5 | --color-bg-tertiary: #E5E5E5; |
| 6 | --color-bg-elevated: #FFFFFF; |
| 7 | |
| 8 | /* Text colors */ |
| 9 | --color-text-primary: #0D0D0D; |
| 10 | --color-text-secondary: #525252; |
| 11 | --color-text-muted: #808080; |
| 12 | --color-text-inverse: #FFFFFF; |
| 13 | |
| 14 | /* Accent colors */ |
| 15 | --color-accent: #00FF41; |
| 16 | --color-accent-hover: #00CC34; |
| 17 | --color-accent-muted: rgba(0, 255, 65, 0.1); |
| 18 | |
| 19 | /* Semantic colors */ |
| 20 | --color-success: #22C55E; |
| 21 | --color-warning: #FFB000; |
| 22 | --color-error: #EF4444; |
| 23 | --color-info: #3B82F6; |
| 24 | |
| 25 | /* Border & shadow */ |
| 26 | --color-border: #E5E5E5; |
| 27 | --color-border-subtle: #F0F0F0; |
| 28 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); |
| 29 | --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); |
| 30 | --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12); |
| 31 | } |
The prefers-color-scheme media query detects the user's operating system theme preference. It returns dark when the OS is set to dark mode, light for light mode, and can also match no-preference when the OS has no explicit setting. This is the zero-JavaScript path to dark mode.
Define light theme values in :root (the default) and override them under @media (prefers-color-scheme: dark). This approach requires no JavaScript and respects the user's system-wide choice — but it doesn't allow in-page toggling.
| 1 | :root { |
| 2 | --bg: #FFFFFF; |
| 3 | --text: #0D0D0D; |
| 4 | --surface: #F5F5F5; |
| 5 | --border: #E5E5E5; |
| 6 | --accent: #00FF41; |
| 7 | --muted: #808080; |
| 8 | } |
| 9 | |
| 10 | @media (prefers-color-scheme: dark) { |
| 11 | :root { |
| 12 | --bg: #0D0D0D; |
| 13 | --text: #E0E0E0; |
| 14 | --surface: #1A1A1A; |
| 15 | --border: #222222; |
| 16 | --accent: #00FF41; |
| 17 | --muted: #525252; |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | /* Also handle no-preference */ |
| 22 | @media (prefers-color-scheme: no-preference) { |
| 23 | :root { |
| 24 | /* Default to dark for users who haven't chosen */ |
| 25 | --bg: #0D0D0D; |
| 26 | --text: #E0E0E0; |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | body { |
| 31 | background: var(--bg); |
| 32 | color: var(--text); |
| 33 | font-family: system-ui; |
| 34 | transition: background-color 0.3s, color 0.3s; |
| 35 | } |
note
The most flexible approach is toggling a .dark class on the <html> element. All dark mode overrides live under a single .dark selector, and JavaScript adds or removes the class based on user preference. This pattern integrates with localStorage for persistence and matchMedia for system detection.
The key advantage of class-based theming is granular control: you can combine it with prefers-color-scheme for a default, then let the class override. This also supports scoped themes — apply .dark to a specific container instead of the root for embedded dark panels.
| 1 | :root { |
| 2 | --bg-primary: #FFFFFF; |
| 3 | --bg-secondary: #F5F5F5; |
| 4 | --bg-surface: #FAFAFA; |
| 5 | --text-primary: #0D0D0D; |
| 6 | --text-secondary: #525252; |
| 7 | --text-muted: #808080; |
| 8 | --accent: #00FF41; |
| 9 | --accent-hover: #00CC34; |
| 10 | --border: #E5E5E5; |
| 11 | --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); |
| 12 | } |
| 13 | |
| 14 | .dark { |
| 15 | --bg-primary: #0D0D0D; |
| 16 | --bg-secondary: #1A1A1A; |
| 17 | --bg-surface: #141414; |
| 18 | --text-primary: #E0E0E0; |
| 19 | --text-secondary: #A0A0A0; |
| 20 | --text-muted: #525252; |
| 21 | --accent: #00FF41; |
| 22 | --accent-hover: #33FF66; |
| 23 | --border: #222222; |
| 24 | --shadow: 0 1px 3px rgba(0, 0, 0, 0.4); |
| 25 | } |
| 26 | |
| 27 | body { |
| 28 | background-color: var(--bg-primary); |
| 29 | color: var(--text-primary); |
| 30 | transition: background-color 0.3s ease, color 0.3s ease; |
| 31 | } |
A production theme manager needs to solve three problems: persist the user's choice across page loads, detect the system preference as a default, and prevent flash of wrong theme (FOUC) during the initial paint. The script below handles all three by executing synchronously in the <head> before any content renders.
The theme manager reads localStorage for an explicit user choice. If none exists, it checks the system preference via matchMedia. The class is applied to <html> immediately — before CSS parses — eliminating any flash. A <script> tag in <head> with this code is the standard pattern.
| 1 | // Inline in <head> to prevent FOUC |
| 2 | (function() { |
| 3 | const stored = localStorage.getItem('theme'); |
| 4 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
| 5 | const theme = stored || (prefersDark ? 'dark' : 'light'); |
| 6 | document.documentElement.classList.add(theme); |
| 7 | document.documentElement.setAttribute('data-theme', theme); |
| 8 | })(); |
| 9 | |
| 10 | // Theme toggle function |
| 11 | function toggleTheme() { |
| 12 | const html = document.documentElement; |
| 13 | const current = html.classList.contains('dark') ? 'dark' : 'light'; |
| 14 | const next = current === 'dark' ? 'light' : 'dark'; |
| 15 | |
| 16 | html.classList.remove(current); |
| 17 | html.classList.add(next); |
| 18 | html.setAttribute('data-theme', next); |
| 19 | localStorage.setItem('theme', next); |
| 20 | |
| 21 | // Update toggle button icon |
| 22 | const btn = document.querySelector('[data-theme-toggle]'); |
| 23 | if (btn) btn.textContent = next === 'dark' ? '\u2600' : '\u263E'; |
| 24 | } |
| 25 | |
| 26 | // Listen for system theme changes |
| 27 | window.matchMedia('(prefers-color-scheme: dark)') |
| 28 | .addEventListener('change', (e) => { |
| 29 | if (!localStorage.getItem('theme')) { |
| 30 | const theme = e.matches ? 'dark' : 'light'; |
| 31 | document.documentElement.classList.add(theme); |
| 32 | document.documentElement.setAttribute('data-theme', theme); |
| 33 | } |
| 34 | }); |
warning
FOUC occurs when the browser renders content before the correct theme is applied. On a page with dark mode, users might see a brief white flash before the dark theme loads. The fix is a synchronous inline script that runs before the browser paints. You can also add a CSS rule that defaults to the most common theme.
| 1 | <!-- Option 1: Inline script in <head> (recommended) --> |
| 2 | <head> |
| 3 | <script> |
| 4 | (function() { |
| 5 | var t = localStorage.getItem('theme') |
| 6 | || (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light'); |
| 7 | document.documentElement.classList.add(t); |
| 8 | })(); |
| 9 | </script> |
| 10 | <link rel="stylesheet" href="/styles.css" /> |
| 11 | </head> |
| 12 | |
| 13 | <!-- Option 2: CSS-only default (less flexible) --> |
| 14 | <style> |
| 15 | :root { color-scheme: light; } |
| 16 | .dark { color-scheme: dark; } |
| 17 | </style> |
| 18 | |
| 19 | <!-- Option 3: Meta tag color-scheme --> |
| 20 | <head> |
| 21 | <meta name="color-scheme" content="light dark" /> |
| 22 | </head> |
info
A production-ready theme system goes beyond swapping background and text colors. It covers shadows, borders, gradients, focus rings, scrollbars, form controls, and every interactive state. The full token set below provides semantic naming, WCAG AA contrast ratios, and theme-aware states for hover, active, focus, and disabled.
| 1 | :root { |
| 2 | color-scheme: light dark; |
| 3 | |
| 4 | /* Backgrounds */ |
| 5 | --bg-base: #FFFFFF; |
| 6 | --bg-surface: #F9FAFB; |
| 7 | --bg-elevated: #FFFFFF; |
| 8 | --bg-overlay: rgba(0, 0, 0, 0.5); |
| 9 | --bg-muted: #F3F4F6; |
| 10 | |
| 11 | /* Text */ |
| 12 | --text-base: #111827; |
| 13 | --text-secondary: #6B7280; |
| 14 | --text-muted: #9CA3AF; |
| 15 | --text-inverse: #FFFFFF; |
| 16 | --text-accent: #059669; |
| 17 | |
| 18 | /* Interactive */ |
| 19 | --accent-base: #059669; |
| 20 | --accent-hover: #047857; |
| 21 | --accent-active: #065F46; |
| 22 | --accent-subtle: rgba(5, 150, 105, 0.1); |
| 23 | --accent-focus-ring: rgba(5, 150, 105, 0.4); |
| 24 | |
| 25 | /* Borders */ |
| 26 | --border-default: #E5E7EB; |
| 27 | --border-strong: #D1D5DB; |
| 28 | --border-subtle: #F3F4F6; |
| 29 | |
| 30 | /* Shadows */ |
| 31 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); |
| 32 | --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07); |
| 33 | --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1); |
| 34 | --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.06); |
| 35 | |
| 36 | /* Semantic */ |
| 37 | --success: #16A34A; |
| 38 | --warning: #D97706; |
| 39 | --error: #DC2626; |
| 40 | --info: #2563EB; |
| 41 | |
| 42 | /* Focus */ |
| 43 | --focus-ring: 0 0 0 3px var(--accent-focus-ring); |
| 44 | |
| 45 | /* Radius */ |
| 46 | --radius-sm: 6px; |
| 47 | --radius-md: 8px; |
| 48 | --radius-lg: 12px; |
| 49 | --radius-full: 9999px; |
| 50 | |
| 51 | /* Transitions */ |
| 52 | --transition-fast: 150ms ease; |
| 53 | --transition-normal: 250ms ease; |
| 54 | --transition-slow: 400ms ease; |
| 55 | } |
| 56 | |
| 57 | .dark { |
| 58 | --bg-base: #0A0A0A; |
| 59 | --bg-surface: #141414; |
| 60 | --bg-elevated: #1C1C1C; |
| 61 | --bg-overlay: rgba(0, 0, 0, 0.7); |
| 62 | --bg-muted: #1C1C1C; |
| 63 | |
| 64 | --text-base: #F3F4F6; |
| 65 | --text-secondary: #9CA3AF; |
| 66 | --text-muted: #6B7280; |
| 67 | --text-inverse: #111827; |
| 68 | --text-accent: #34D399; |
| 69 | |
| 70 | --accent-base: #34D399; |
| 71 | --accent-hover: #6EE7B7; |
| 72 | --accent-active: #A7F3D0; |
| 73 | --accent-subtle: rgba(52, 211, 153, 0.1); |
| 74 | --accent-focus-ring: rgba(52, 211, 153, 0.3); |
| 75 | |
| 76 | --border-default: #262626; |
| 77 | --border-strong: #333333; |
| 78 | --border-subtle: #1F1F1F; |
| 79 | |
| 80 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); |
| 81 | --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); |
| 82 | --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5); |
| 83 | --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); |
| 84 | |
| 85 | --success: #4ADE80; |
| 86 | --warning: #FBBF24; |
| 87 | --error: #F87171; |
| 88 | --info: #60A5FA; |
| 89 | |
| 90 | --focus-ring: 0 0 0 3px var(--accent-focus-ring); |
| 91 | } |
| 92 | |
| 93 | /* Apply tokens */ |
| 94 | body { |
| 95 | background-color: var(--bg-base); |
| 96 | color: var(--text-base); |
| 97 | transition: background-color var(--transition-normal), color var(--transition-normal); |
| 98 | } |
| 99 | |
| 100 | a { color: var(--text-accent); } |
| 101 | a:hover { color: var(--accent-hover); } |
| 102 | :focus-visible { outline: none; box-shadow: var(--focus-ring); } |
best practice
WCAG 2.1 requires a minimum contrast ratio of 4.5:1 for normal text (AA) and 3:1 for large text. AAA requires 7:1 for normal text. Dark mode often fails these thresholds because light-on-dark combinations that look vibrant to developers with good vision may be unreadable for users with low vision or color blindness.
Test every theme independently with a contrast checker. The --text-secondary token must hit 4.5:1 against both --bg-base in light mode and dark mode. Focus indicators are also frequently neglected — a :focus-visible ring that looks great on light backgrounds may disappear on dark surfaces.
| 1 | /* Focus ring that works in both themes */ |
| 2 | :focus-visible { |
| 3 | outline: none; |
| 4 | box-shadow: 0 0 0 2px var(--bg-base), 0 0 0 4px var(--accent-base); |
| 5 | } |
| 6 | |
| 7 | /* High-contrast focus for dark mode */ |
| 8 | .dark :focus-visible { |
| 9 | box-shadow: 0 0 0 2px var(--bg-base), 0 0 0 4px var(--accent-base); |
| 10 | } |
| 11 | |
| 12 | /* Skip-to-content link */ |
| 13 | .skip-link { |
| 14 | position: absolute; |
| 15 | top: -100%; |
| 16 | left: 16px; |
| 17 | padding: 8px 16px; |
| 18 | background: var(--accent-base); |
| 19 | color: var(--text-inverse); |
| 20 | border-radius: var(--radius-md); |
| 21 | font-weight: 600; |
| 22 | z-index: 100; |
| 23 | } |
| 24 | |
| 25 | .skip-link:focus { |
| 26 | top: 16px; |
| 27 | } |
| 28 | |
| 29 | /* Reduced motion: disable transitions */ |
| 30 | @media (prefers-reduced-motion: reduce) { |
| 31 | *, *::before, *::after { |
| 32 | transition-duration: 0.01ms !important; |
| 33 | animation-duration: 0.01ms !important; |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | /* High contrast mode support */ |
| 38 | @media (forced-colors: active) { |
| 39 | .btn { |
| 40 | border: 1px solid ButtonText; |
| 41 | background: ButtonFace; |
| 42 | color: ButtonText; |
| 43 | } |
| 44 | } |
warning
Beyond light and dark, many applications benefit from additional themes: sepia for reading, high-contrast for accessibility, solarized for reduced blue light, or fully custom brand themes. The data-theme attribute approach scales cleanly — each theme is a separate selector block that overrides the same set of tokens.
| 1 | /* Sepia theme */ |
| 2 | [data-theme="sepia"] { |
| 3 | --bg-base: #F4ECD8; |
| 4 | --bg-surface: #EDE3CC; |
| 5 | --text-base: #433422; |
| 6 | --text-secondary: #6B5744; |
| 7 | --accent-base: #B8860B; |
| 8 | --accent-hover: #996B09; |
| 9 | --border-default: #D4C4A8; |
| 10 | } |
| 11 | |
| 12 | /* High-contrast theme */ |
| 13 | [data-theme="contrast"] { |
| 14 | --bg-base: #000000; |
| 15 | --bg-surface: #0A0A0A; |
| 16 | --text-base: #FFFFFF; |
| 17 | --text-secondary: #E0E0E0; |
| 18 | --accent-base: #FFD700; |
| 19 | --accent-hover: #FFEC80; |
| 20 | --border-default: #FFFFFF; |
| 21 | --shadow-md: none; |
| 22 | } |
| 23 | |
| 24 | /* Dracula-inspired */ |
| 25 | [data-theme="dracula"] { |
| 26 | --bg-base: #282A36; |
| 27 | --bg-surface: #343746; |
| 28 | --bg-elevated: #44475A; |
| 29 | --text-base: #F8F8F2; |
| 30 | --text-secondary: #BFBFBF; |
| 31 | --accent-base: #50FA7B; |
| 32 | --accent-hover: #69FF94; |
| 33 | --border-default: #44475A; |
| 34 | } |
| 35 | |
| 36 | /* Nord */ |
| 37 | [data-theme="nord"] { |
| 38 | --bg-base: #2E3440; |
| 39 | --bg-surface: #3B4252; |
| 40 | --bg-elevated: #434C5E; |
| 41 | --text-base: #ECEFF4; |
| 42 | --text-secondary: #D8DEE9; |
| 43 | --accent-base: #88C0D0; |
| 44 | --accent-hover: #8FBCBB; |
| 45 | --border-default: #4C566A; |
| 46 | } |
| 47 | |
| 48 | /* Tokyo Night */ |
| 49 | [data-theme="tokyo"] { |
| 50 | --bg-base: #1A1B26; |
| 51 | --bg-surface: #24283B; |
| 52 | --bg-elevated: #414868; |
| 53 | --text-base: #C0CAF5; |
| 54 | --text-secondary: #9AA5CE; |
| 55 | --accent-base: #7AA2F7; |
| 56 | --accent-hover: #89B4FA; |
| 57 | --border-default: #3B4261; |
| 58 | } |
Smooth theme transitions make the switch feel polished. Apply a transition to the body element that covers background-color and color. For individual components, transition border-color, box-shadow, and any other properties that change between themes.
Respect prefers-reduced-motion by disabling theme transitions for users who have requested reduced animation. The color-scheme property also tells the browser to transition its own UI elements (scrollbars, form controls) when the theme changes.
| 1 | /* Global theme transition */ |
| 2 | body, |
| 3 | body * { |
| 4 | transition: |
| 5 | background-color 0.3s ease, |
| 6 | color 0.3s ease, |
| 7 | border-color 0.3s ease, |
| 8 | box-shadow 0.3s ease; |
| 9 | } |
| 10 | |
| 11 | /* But disable for elements that shouldn't animate */ |
| 12 | .no-transition, |
| 13 | .no-transition * { |
| 14 | transition: none !important; |
| 15 | } |
| 16 | |
| 17 | /* Respect reduced motion preference */ |
| 18 | @media (prefers-reduced-motion: reduce) { |
| 19 | body, |
| 20 | body * { |
| 21 | transition: none !important; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | /* Color scheme for browser UI */ |
| 26 | :root { |
| 27 | color-scheme: light dark; |
| 28 | } |
| 29 | |
| 30 | /* Scrollbar theming */ |
| 31 | ::-webkit-scrollbar { |
| 32 | width: 8px; |
| 33 | } |
| 34 | |
| 35 | ::-webkit-scrollbar-track { |
| 36 | background: var(--bg-surface); |
| 37 | } |
| 38 | |
| 39 | ::-webkit-scrollbar-thumb { |
| 40 | background: var(--border-default); |
| 41 | border-radius: 4px; |
| 42 | } |
| 43 | |
| 44 | ::-webkit-scrollbar-thumb:hover { |
| 45 | background: var(--border-strong); |
| 46 | } |
| 47 | |
| 48 | /* Form control theming */ |
| 49 | input, textarea, select { |
| 50 | background-color: var(--bg-base); |
| 51 | color: var(--text-base); |
| 52 | border: 1px solid var(--border-default); |
| 53 | color-scheme: light dark; |
| 54 | } |
| 55 | |
| 56 | .dark input, .dark textarea, .dark select { |
| 57 | color-scheme: dark; |
| 58 | } |
pro tip
Images with white backgrounds look harsh on dark surfaces. The filter: brightness() approach dims images slightly in dark mode to blend them into the darker environment. For SVGs, use currentColor to make strokes and fills adapt to the text color automatically.
| 1 | /* Dim images in dark mode */ |
| 2 | .dark img:not([src*=".svg"]) { |
| 3 | filter: brightness(0.85) contrast(1.05); |
| 4 | } |
| 5 | |
| 6 | /* Logo swapping */ |
| 7 | .logo-light { display: block; } |
| 8 | .logo-dark { display: none; } |
| 9 | |
| 10 | .dark .logo-light { display: none; } |
| 11 | .dark .logo-dark { display: block; } |
| 12 | |
| 13 | /* Or use the picture element */ |
| 14 | /* <picture> |
| 15 | <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)" /> |
| 16 | <img src="logo-light.svg" alt="Logo" /> |
| 17 | </picture> */ |
| 18 | |
| 19 | /* SVG that adapts to theme */ |
| 20 | .icon { |
| 21 | width: 24px; |
| 22 | height: 24px; |
| 23 | color: var(--text-base); |
| 24 | fill: currentColor; |
| 25 | stroke: currentColor; |
| 26 | } |
| 27 | |
| 28 | /* Dark-mode-specific image via CSS */ |
| 29 | .hero-image { |
| 30 | background-image: url("/images/hero-light.png"); |
| 31 | } |
| 32 | |
| 33 | .dark .hero-image { |
| 34 | background-image: url("/images/hero-dark.png"); |
| 35 | } |
| 36 | |
| 37 | /* Inline SVG with theme-aware colors */ |
| 38 | .svg-icon path { |
| 39 | fill: var(--text-base); |
| 40 | transition: fill 0.3s ease; |
| 41 | } |
Focus indicators are a WCAG 2.1 requirement and one of the most commonly broken elements in dark mode implementations. A blue outline that's visible on a white background may be invisible on dark surfaces. Your focus ring must use tokens that adapt to both themes.
| 1 | /* Double-ring focus: inner ring matches bg, outer ring is accent */ |
| 2 | :focus-visible { |
| 3 | outline: none; |
| 4 | box-shadow: |
| 5 | 0 0 0 2px var(--bg-base), |
| 6 | 0 0 0 4px var(--accent-base); |
| 7 | } |
| 8 | |
| 9 | /* Works in both themes because tokens adapt */ |
| 10 | /* Light: white inner ring + green outer ring on white bg */ |
| 11 | /* Dark: dark inner ring + green outer ring on dark bg */ |
| 12 | |
| 13 | /* Interactive elements need clear focus + hover states */ |
| 14 | .button { |
| 15 | background: var(--accent-base); |
| 16 | color: var(--text-inverse); |
| 17 | border: none; |
| 18 | padding: 10px 20px; |
| 19 | border-radius: var(--radius-md); |
| 20 | font-weight: 600; |
| 21 | cursor: pointer; |
| 22 | transition: |
| 23 | background-color var(--transition-fast), |
| 24 | box-shadow var(--transition-fast), |
| 25 | transform var(--transition-fast); |
| 26 | } |
| 27 | |
| 28 | .button:hover { |
| 29 | background: var(--accent-hover); |
| 30 | transform: translateY(-1px); |
| 31 | } |
| 32 | |
| 33 | .button:active { |
| 34 | background: var(--accent-active); |
| 35 | transform: translateY(0); |
| 36 | } |
| 37 | |
| 38 | .button:focus-visible { |
| 39 | box-shadow: |
| 40 | 0 0 0 2px var(--bg-base), |
| 41 | 0 0 0 4px var(--accent-base), |
| 42 | 0 4px 12px var(--accent-subtle); |
| 43 | } |
| 44 | |
| 45 | /* Link focus */ |
| 46 | a:focus-visible { |
| 47 | outline: 2px solid var(--accent-base); |
| 48 | outline-offset: 2px; |
| 49 | border-radius: 2px; |
| 50 | } |
Dark mode implementations fail in predictable ways. Here are the most frequent pitfalls and their fixes. Each mistake compounds — a site that hardcodes colors and ignores contrast will have a dark mode that's worse than no dark mode at all.
| Mistake | Impact | Fix |
|---|---|---|
| Hardcoded colors | Colors don't change with theme | Use CSS variables for every color value |
| No focus states in dark mode | Keyboard users can't navigate | Test focus rings in both themes |
| Low contrast in dark mode | Text unreadable for low-vision users | Check all combinations with contrast checker |
| No FOUC prevention | Flash of wrong theme on load | Inline script in <head> before paint |
| White images on dark bg | Harsh contrast, visual noise | Use filter: brightness() or dark variants |
| Ignoring reduced motion | Motion-sensitive users get nauseated | Disable transitions under prefers-reduced-motion |
| No localStorage persistence | Preference resets on every visit | Store choice and read on page load |
| Forgetting form controls | Native inputs stay white in dark mode | Use color-scheme: dark on inputs |
info
Use this checklist when implementing or auditing a dark mode system. Every item directly impacts usability, accessibility, or developer experience.
| Category | Checklist Item | Priority |
|---|---|---|
| Tokens | All colors defined as CSS custom properties | Critical |
| Tokens | Semantic naming (not raw color values) | High |
| Tokens | Shadows and borders are theme-aware | High |
| FOUC | Inline script in <head> prevents flash | Critical |
| FOUC | color-scheme meta/CSS for browser chrome | High |
| A11y | WCAG AA contrast (4.5:1) in both themes | Critical |
| A11y | Focus indicators visible in both themes | Critical |
| A11y | prefers-reduced-motion disables transitions | High |
| UX | Smooth transition between themes (0.3s) | Medium |
| UX | Preference persisted in localStorage | High |
| UX | System preference respected as default | Medium |
| Images | Dark variants or dimming for images | Medium |
| Images | SVGs use currentColor for adaptation | Medium |
| Forms | Form controls themed via color-scheme | High |
| Testing | Both themes tested on real devices | High |