Implementing Dark Mode with Tailwind CSS: Beyond the Toggle Switch
Dark mode seems simple: add the dark class, prefix some utilities with dark:, done. But production dark mode involves: color system design (not just inverting), user preference detection (system settings AND manual override), persistence (remembering the choice), flash prevention (no white flash on dark-mode page loads), and consistent implementation (every component handles both modes).
Here is the complete implementation for Tailwind CSS in a Next.js application.
Step 1: Color System Design
The biggest mistake: designing for light mode and then "adding dark mode" by inverting colors. Inverted colors look wrong — pure white text on pure black backgrounds is harsh. Light gray on dark gray is more comfortable.
Instead: design a color system with semantic tokens that map to different values per mode.
Define semantic colors: background (page background), surface (card/container background), text-primary (main text), text-secondary (muted text), border (dividing lines), and accent (interactive elements).
In light mode: background is white, surface is gray-50, text-primary is gray-900.
In dark mode: background is gray-950, surface is gray-900, text-primary is gray-100.
Implementation in Tailwind: use CSS custom properties. Define them in your globals.css under :root (light) and .dark (dark mode). Reference them in tailwind.config: background: 'hsl(var(--background))'.
This way: your component classes never change. bg-background always works. The custom properties determine the actual color based on the active theme.
Step 2: System Preference Detection
Users set their OS to dark or light mode. Respect this preference by default:
The prefers-color-scheme media query detects the system preference. In Tailwind: configure darkMode: 'class' (not 'media') so you have explicit control via the dark class on the html element.
On page load: check if the user has a saved preference (localStorage). If yes: use their explicit choice. If no: check the system preference via window.matchMedia('(prefers-color-scheme: dark)'). Apply the appropriate class.
Also: listen for system preference changes. If the user switches their OS from light to dark while your page is open: update to match (unless they have explicitly chosen a mode on your site).
Step 3: Avoiding the Flash
The dreaded "flash of wrong theme": page loads in light mode, JavaScript runs, detects dark preference, switches to dark mode. For a fraction of a second: the user sees a white page flash before dark mode activates.
Solution: inline a script in the head (before any content renders) that checks the preference and applies the dark class immediately. This script runs synchronously before the browser paints.
In Next.js App Router: add a script tag in your root layout that runs before hydration. Check localStorage and system preference. Apply the dark class to document.documentElement. This prevents any flash because the class is set before content is visible.
The script must be minimal and synchronous: no imports, no async operations, just: check storage → check system → apply class.
Step 4: The Toggle Component
The toggle: a button/switch that cycles between light, dark, and system preference.
Three states (not two): light (user explicitly chose light), dark (user explicitly chose dark), and system (follow OS preference — the default).
Why three states: if you only offer light/dark: users who want to follow their system preference have no option. They manually set dark mode, then change their OS to light for daytime — but your site stays dark because they explicitly set it.
Implementation: store the choice in localStorage. On toggle: update localStorage, update the dark class on html, and re-evaluate (if "system": check prefers-color-scheme and apply accordingly).
Step 5: Persistent Preference
Store the theme choice in localStorage (client-side): immediately available on next page load (no server round-trip). The inline head script reads this value before rendering.
For authenticated users: also store the preference in the database. Sync on login (apply their server-stored preference). This way: their theme choice follows them across devices.
Priority: localStorage (fastest, no network) → database (if authenticated, for cross-device sync) → system preference (default).
Step 6: Color Palette for Both Modes
Designing colors that work in both modes:
Text: do not use pure black (#000) or pure white (#FFF). Use gray-900 / gray-100 — slightly softer, less harsh.
Backgrounds: layer surfaces. Page background → card background → nested element background. Each layer should have subtle contrast difference in both modes.
Borders: use transparent alpha values (border-white/10 or border-black/10) rather than fixed colors. Alpha values adapt naturally to both backgrounds.
Shadows: in light mode, shadows are visible and provide depth. In dark mode: shadows are less effective (dark shadow on dark background). Consider using subtle borders or lighter surface colors for elevation instead of shadows in dark mode.
Accent colors: bright saturated colors work in both modes but may need lightness adjustment. A blue that looks great on white might be too dark on a dark background. Define accent-light and accent-dark variants.
Step 7: Images and Media
Images designed for light mode (white backgrounds) look out of place in dark mode. Solutions:
SVGs: can be recolored via CSS. Use currentColor for SVG fills — they adapt to text color automatically.
Logos: provide light and dark variants. Show the appropriate variant based on the active theme. (Many brands provide both versions of their logo.)
Screenshots and photos: apply subtle CSS filters in dark mode — brightness(0.9) reduces harsh whites. Or provide alternative dark-mode screenshots.
Decorative backgrounds: CSS gradients and patterns should have dark-mode variants.
Step 8: Testing Dark Mode
Manual testing: switch to dark mode and check every page. Look for: text unreadable against backgrounds (contrast issues), borders invisible (same color as background), images with white backgrounds that look jarring, and form inputs invisible (default browser styles may not adapt).
Automated testing: Lighthouse contrast checks in both modes. Screenshot testing (Chromatic, Playwright) capturing both light and dark variants. Any visual regression is caught.
Step 9: Component-Level Implementation
Every component should work in both modes without special props. The color system handles this: bg-surface (light: gray-50, dark: gray-900). Components never need dark: prefixes on individual utilities because the CSS custom properties handle the switching.
Exception: when a component needs different structure in dark mode (border instead of shadow for elevation). Use the dark: prefix sparingly for these structural differences.
Our approach: 95% of dark mode is handled by the color system (CSS custom properties). 5% uses dark: prefixes for specific overrides (shadow → border swap, image filter adjustments).
Step 10: Gradual Implementation
If adding dark mode to an existing application: do not try to convert everything at once.
Phase 1: set up the infrastructure (color system, toggle, preference detection, flash prevention).
Phase 2: convert the layout (header, sidebar, footer — the chrome).
Phase 3: convert page by page, starting with the most visited pages.
Phase 4: convert remaining pages and edge cases.
At each phase: dark mode partially works. Some pages are converted, others are not. This is fine for a gradual rollout. Use the dark class on the html element for pages that are ready, and provide a "dark mode is in beta" notice.
The Result
A properly implemented dark mode: respects user preference (system, explicit choice, and persistence), loads without flash (inline detection script), looks intentionally designed (not just inverted), works consistently across all components, and requires minimal per-component effort (color system does the heavy lifting). This takes effort to implement correctly — but once the system is in place, new components automatically support dark mode with zero additional work.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.