Dark Mode Implementation Cheat Sheet
Covers CSS custom-property theming, prefers-color-scheme, flash-of-wrong-theme prevention, persisted toggle logic, and Tailwind's class-based dark mode setup.
Theming Strategies
The building blocks behind most dark mode implementations.
- data-theme attribute- Set via JS on <html>, paired with CSS attribute selectors for explicit, user-controlled themes
- prefers-color-scheme- Media query reflecting the OS/browser-level light or dark preference
- color-scheme (CSS property)- Tells the browser to render native form controls and scrollbars to match your theme
- CSS custom properties- Centralize color tokens as variables so switching themes only reassigns values, not selectors
- FOUC- Flash of unstyled/incorrect content, avoided by reading the saved theme before first paint
- localStorage persistence- Remembers an explicit user choice across visits, overriding the OS default
CSS Variables & Media Query
Theme tokens with an OS-preference fallback.
:root { --bg: #ffffff; --text: #1a1a1a; --accent: #2563eb; color-scheme: light; /* native controls/scrollbars render light */}[data-theme="dark"] { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; color-scheme: dark;}/* Fall back to OS preference only when no explicit choice is set */@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0f172a; --text: #e2e8f0; }}body { background: var(--bg); color: var(--text); transition: background 0.2s ease, color 0.2s ease;}
Prevent Flash of Wrong Theme
Blocking inline script that runs before the CSS paints.
<!-- In <head>, before stylesheets --><script> (function () { var saved = localStorage.getItem('theme'); var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.setAttribute('data-theme', saved || (systemDark ? 'dark' : 'light')); })();</script>
Toggle & Persist Theme
User-triggered switch that also reacts to OS changes.
function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme);}document.querySelector('#theme-toggle').addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme'); setTheme(current === 'dark' ? 'light' : 'dark');});// Follow OS changes only if the user hasn't made an explicit choicewindow.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { setTheme(e.matches ? 'dark' : 'light'); }});
Tailwind Dark Mode
Class-based strategy for full manual control.
// tailwind.config.jsmodule.exports = { darkMode: 'class', // toggled via a .dark class on <html>, instead of 'media' theme: { extend: {} },};// Usage in markup:// <div class="bg-white dark:bg-slate-900 text-black dark:text-slate-100">
system-ui Contextual Colors with light-dark()
The modern CSS light-dark() function resolves both palettes from a single declaration without duplicating selectors.
:root { color-scheme: light dark; /* let the UA pick native widget rendering */ --bg: light-dark(#ffffff, #0f172a); --text: light-dark(#1a1a1a, #e2e8f0); --border: light-dark(#e2e8f0, #1e293b);}/* Explicit override still wins because :root[data-theme] has higher specificity plus you can pin color-scheme per branch */:root[data-theme="dark"] { color-scheme: dark;}:root[data-theme="light"] { color-scheme: light;}body { background: var(--bg); color: var(--text); border-color: var(--border);}
React Theme Context with System Sync
A production-grade provider that tracks 'light' | 'dark' | 'system' and reacts to live OS changes.
type ThemePref = 'light' | 'dark' | 'system';const ThemeCtx = createContext<{ theme: ThemePref; resolved: 'light' | 'dark'; setTheme: (t: ThemePref) => void } | null>(null);export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<ThemePref>(() => (localStorage.getItem('theme') as ThemePref) || 'system'); const [resolved, setResolved] = useState<'light' | 'dark'>('light'); useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)'); const apply = () => { const next = theme === 'system' ? (mq.matches ? 'dark' : 'light') : theme; setResolved(next); document.documentElement.setAttribute('data-theme', next); }; apply(); mq.addEventListener('change', apply); localStorage.setItem('theme', theme); return () => mq.removeEventListener('change', apply); }, [theme]); return <ThemeCtx.Provider value={{ theme, resolved, setTheme }}>{children}</ThemeCtx.Provider>;}export const useTheme = () => useContext(ThemeCtx)!;
Theme-Aware Images & prefers-contrast
Swap raster assets per theme without JS, and layer in a high-contrast fallback.
/* Swap a logo/illustration purely with CSS */.logo { content: url('/logo-light.svg'); }[data-theme="dark"] .logo { content: url('/logo-dark.svg'); }/* <picture> alternative for real <img> elements *//* <picture> <source srcset="/hero-dark.avif" media="(prefers-color-scheme: dark)"> <img src="/hero-light.avif" alt="Hero"> </picture> *//* Respect forced-colors / prefers-contrast alongside dark mode */@media (prefers-contrast: more) { [data-theme="dark"] { --bg: #000000; --text: #ffffff; --border: #ffffff; }}@media (forced-colors: active) { .card { forced-color-adjust: none; border: 1px solid CanvasText; }}
Animated Theme Switch with View Transitions
Circular reveal animation on toggle using the View Transitions API, with a reduced-motion escape hatch.
function toggleThemeAnimated(x, y) { const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!document.startViewTransition || prefersReduced) { document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); return; } const transition = document.startViewTransition(() => { document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); }); transition.ready.then(() => { const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)); document.documentElement.animate( { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] }, { duration: 400, easing: 'ease-in', pseudoElement: '::view-transition-new(root)' } ); });}
Common Dark Mode Pitfalls
Subtle bugs that only surface once real content and third-party widgets enter a dark theme.
- Pure black backgrounds (#000)- Causes halation/glow on OLED screens for bright text; prefer a dark gray like #0f172a
- Un-themed iframes/embeds- Maps, ads, and payment widgets often ignore your CSS variables and need explicit dark-mode query params or postMessage config
- Box-shadow invisibility- Shadows tuned for light backgrounds vanish on dark ones; swap to lighter, more diffuse shadows or a border instead
- Image glare- Photos with white backgrounds look like bright rectangles; consider dimming with filter: brightness(.8) or adding a rounded container
- Inverted syntax-highlighting themes- Code blocks need a dedicated dark theme (e.g. github-dark), not a CSS filter: invert() hack
- Hardcoded inline styles- style="color:#000" in CMS/rich-text content bypasses your variables entirely and needs sanitization or !important overrides
- Print stylesheets- @media print should force light colors regardless of data-theme, or printed pages waste ink on dark backgrounds
Set the color-scheme CSS property alongside your custom theme — it tells the browser to render native widgets like scrollbars, checkboxes, and date pickers in the matching light or dark style, so your theme doesn't feel broken at the edges.