5 CSS Variable Dark Mode Systems
5 CSS variable dark mode systems for SaaS appearance settings, marketing sites, and app dashboards — spanning a no-JavaScript prefers-color-scheme baseline, a data-theme toggle with localStorage persistence, the inline head script that kills the reload flash, a System/Light/Dark three-way switcher with live OS sync, and a modern light-dark() token setup. Scoped under .cdm-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.
Related10 CSS Dark Mode Toggles22 CSS Dark Mode UI22 CSS Glassmorphism23 Tailwind CSS Dark Mode UI
Course pickPartner: Zero to Mastery
Affiliate linkWant to build production-ready CSS layouts, animations and UI from scratch?
You just browsed 5 dark mode systems. Learn the Flexbox, Grid and animation thinking behind them, so the next component starts in your head, not in a copy-paste.
Every ZTM course in one membership: $25/month, paid yearly at $299 · 30‑day money-back guarantee
The CSS Bootcamp. We may earn a commission, at no extra cost to you.
02. CSS Variable Dark Mode Toggle with data-theme Attribute and localStorage Persistence (opens in a new tab)
03. Prevent Dark Mode Flash of Unstyled Content (FOUC) with a Blocking Inline Head Script (opens in a new tab)
<head> reads localStorage (falling back to the OS preference) and stamps data-theme onto <html> BEFORE the browser paints a single pixel — so the page loads already-dark, with zero flicker.04. Three-Way Theme Switcher (System, Light, Dark) with matchMedia and CSS Custom Properties (opens in a new tab)
05. Modern Native Dark Mode with the CSS light-dark() Function and color-scheme (opens in a new tab)
<light-value>, <dark-value>) — the browser picks the right one based on the resolved scheme, with no @media block and no duplicated rule set. Manual override becomes a one-liner: change color-scheme to light or dark. This is where CSS dark mode is heading.When to use each system
Working on a specific project? Match your situation to the right pattern here — every row maps a real project constraint to the exact demo above that solves it. Six situations, six answers, no ambiguity.
| Your situation | Pick | Why |
|---|---|---|
| Blog, portfolio, or marketing site with no toggle button | Pure CSS OS sync | Zero JavaScript. Follows the OS setting. Nothing to maintain, no bug surface, ships in five minutes. |
| Docs site or app with a header "Dark" toggle button | data-theme + localStorage | Users get a manual override that survives reloads. Ten lines of vanilla JS, no ThemeProvider dependency. |
| Any production site (mandatory add-on to Demos 02 or 04) | Anti-FOUC head script | Kills the white-flash-on-reload bug that every user notices first. Non-negotiable if you ship a toggle at all. |
| SaaS product with a Preferences / Settings panel | 3-way System / Light / Dark switcher | The standard SaaS pattern users expect. Defaults to System, respects the OS live via matchMedia, allows explicit override. |
| New project, evergreen browsers only, want the shortest code | Native light-dark() function | Media-query-free. Every color declared once with both values inline. 2024 baseline; pair with @supports fallback for older engines. |
| Design-system team building tokens for many products | Combine 03 + 04 + 05 | Anti-FOUC head script for zero flicker, 3-way switcher for user control, light-dark() at the token layer so components stay theme-agnostic. |
5 Pro tips for production-grade dark mode
Small production techniques that separate a working dark-mode implementation from a polished one. Each of these plugs into any of the 5 demos above, and together they cover the invisible details that senior UI engineers ship by default.
Derive dark variants with oklch() relative color syntax
Why: Stop hand-picking 20+ dark shades. Relative color syntax reads an existing color and transforms it — here it drops lightness and eases chroma to procedurally generate a dark surface from one accent.
/* Derive a dark card background from the primary color — no second palette */ .card-dark { background-color: oklch(from var(--primary) calc(l - 0.4) calc(c * 0.7) h); }Sync native browser UI with color-scheme
Why: Fixes stark-white scrollbars, <select> menus, date pickers and text carets in dark mode — with zero custom scrollbar CSS. One declaration makes the browser's own chrome match your theme.
:root { color-scheme: light dark; /* native scrollbars, inputs & dropdowns go dark too */ }Dim bright images in dark mode
Why: Pure-white photos and screenshots glare in a dark theme. A gentle brightness/contrast filter (skipping SVG icons) settles them into the page — a pro UX touch most tutorials skip.
@media (prefers-color-scheme: dark) { img:not([src*=".svg"]) { filter: brightness(0.85) contrast(1.1); } }Smooth theme transitions that respect reduced motion
Why: A soft color blend on theme change feels polished — but must be disabled for users who set prefers-reduced-motion, satisfying WCAG 2.3.3 (Animation from Interactions).
body { transition: background-color .3s ease, color .3s ease; } @media (prefers-reduced-motion: reduce) { body { transition: none; } }Swap elevation & shadows for dark mode
Why: Drop shadows vanish against dark backgrounds. Experienced developers replace them with a subtle inset border-glow — and light-dark() does both in a single declaration.
.card { box-shadow: light-dark( 0 4px 12px rgba(0, 0, 0, 0.08), /* light: soft drop shadow */ 0 0 0 1px rgba(255, 255, 255, 0.15) /* dark: inset border glow */ ); }
Frequently asked questions
What is the 5-variable dark mode system and why does it beat maintaining two full palettes?
--bg (page background), --surface (card / panel background), --text (body text), --border (hairlines and dividers), and --accent (brand / primary interactive color). Every other color the UI needs — muted text, hover fills, focus rings, active states, ghost buttons, disabled states, subtle glows, avatar rings — is derived from those five with color-mix() and oklch() relative color syntax. To switch to dark mode, you only redefine those five values inside @media (prefers-color-scheme: dark) or a [data-theme="dark"] selector; every derived color recalculates for free because the derivations reference the source variables, not literal colors. The alternative — maintaining two hand-tuned palettes of 20-30 colors each — is where dark-mode implementations go wrong: developers pick a new dark shade for every element, palettes drift as the app grows, and eventually the light theme has one indigo accent but the dark theme has three slightly different ones because nobody enforced the mapping. The 5-variable system is what Radix Colors, Tailwind's semantic color layer, GitHub Primer, Material 3 tokens, and Apple's HIG token system all reduce to at their core. The reason we teach it as five variables (not four or six) is that it's the minimum viable set that covers every canonical UI need: any smaller and you have to pick a background AND a card background from the same variable (they read as the same layer); any larger and you're back to hand-tuning proliferation. Every demo in this collection uses this exact 5-variable core — the demos differ only in HOW the swap is triggered (OS sync, click, head script, 3-way toggle, or native light-dark() function).How do I prevent the flash of white on reload in a dark-mode site — the notorious FOUC bug?
<script> as the first thing in <head>, before any CSS or stylesheet link. That script reads localStorage (falling back to matchMedia('(prefers-color-scheme: dark)')) and stamps data-theme onto the <html> element. Because the script is synchronous and blocking, it runs during HTML parse — before the browser paints a single pixel — and when the CSS arrives moments later, the correct variables are already selected. Nothing repaints. Demo 03 in this collection ships the exact copy-paste-ready pattern: about 8 lines of vanilla JS in the head, wrapped in try/catch so a private-mode Safari localStorage exception can never block rendering. Common mistakes that reintroduce the flash: making the script async or defer, loading it as an external <script src>, wrapping it in DOMContentLoaded or window.load, or putting it after your CSS link. All four of those defer the theme-set past the browser's first paint. In Next.js App Router, inject the script as a raw string via <Script strategy="beforeInteractive"> or embed it in app/layout.tsx's <head> directly. In Astro, drop it in the <head> of your Layout component. In SvelteKit, use %sveltekit.head% injection. The head script coexists with your normal toggle button (see Demo 02) — both read and write the same cf-theme localStorage key, so reloads are flicker-free and clicks are immediate. This is the single most important pattern in dark-mode UX because the flash is the first thing every reviewer notices and the first thing every user asks about.How does the native CSS light-dark() function work — and when should I use it instead of @media (prefers-color-scheme)?
light-dark() is a native CSS function (shipped in all three engines in 2024 — Chrome 123+, Safari 17.5+, Firefox 120+) that returns its first argument in light mode and its second argument in dark mode, decided by the element's computed color-scheme property. You write every color once as light-dark(light-value, dark-value), and the browser resolves it based on which scheme is active. Set color-scheme: light dark on the root and the page follows the OS by default; set it to just light or dark (typically via a data-theme attribute) and you force an override. No @media (prefers-color-scheme: dark) block, no duplicated rule sets, no fighting cascade order. Demo 05 in this collection ships the pattern. When to prefer light-dark() over the media-query approach: (1) new projects targeting evergreen browsers where you don't need to support 2023-and-older engines — the terser syntax is easier to maintain long-term; (2) design-system authoring where each color has one canonical definition and both themes live on the same source line; (3) per-component theme overrides — setting color-scheme on a subtree forces that subtree's light-dark() resolutions independently of the page. When to stick with @media (prefers-color-scheme: dark): (1) production traffic that includes older browsers (older Safari, older Firefox, or Chrome before 123 — Demo 01 is the reference pattern); (2) sites where you need to support browsers with light-dark() disabled behind a flag; (3) simple sites where you're already using @media queries for other things. Best practice: pair both. Ship light-dark() as the modern path, gate it behind @supports (color: light-dark(#000, #fff)), and provide the media-query fallback for older engines. Combined with the 5-variable pattern, you get a design token system that reads once, resolves twice, and degrades gracefully. Common gotcha: light-dark() only works when color-scheme includes both keywords on (or above) the element — without it, the function has nothing to resolve against and defaults to the first argument. Always declare color-scheme: light dark on <html> or :root.How do I build a 3-way System / Light / Dark theme switcher that live-updates when the OS flips?
window.matchMedia, but lets users force an explicit override that persists in localStorage. Demo 04 in this collection ships the full implementation. The critical detail most tutorials get wrong: while the user is in System mode, the UI must live-update when the OS toggles. That means registering a change listener on the MediaQueryList and applying the theme when it fires — but only if the user is currently in System mode (not if they've forced Light or Dark). The CSS side uses three data-theme states: [data-theme="light"] and [data-theme="dark"] set the five variables explicitly; [data-theme="system"] defers to the OS via a nested @media (prefers-color-scheme: dark) inside that attribute selector. The nesting is important — if you put the dark media query at the top level, it will still fire for users who've explicitly picked Light. The JS side reads the saved mode from localStorage on load (defaulting to 'system'), writes data-theme on radio change, updates aria-checked on each option, and registers mql.addEventListener('change', () => { if (root.dataset.theme === 'system') apply(); }). Common mistakes: forgetting the change listener (System mode goes stale until reload), using the deprecated addListener instead of addEventListener, forgetting to nest the dark media query under [data-theme="system"], or hard-coding the initial state to 'light' instead of 'system'. The control itself should be a real radiogroup — three <label>-wrapped <input type="radio"> elements — so arrow keys navigate between options and screen readers announce the selection correctly. Never use a <div onclick> pattern; that fails keyboard navigation and screen reader announcement. This is the pattern GitHub, Vercel, Linear, Notion, Cron, and every modern developer tool ships in their preferences panel.Why do I need color-scheme in CSS, and what happens if I skip it?
color-scheme is a CSS property that tells the browser which color palette it should use for the elements it draws itself — native scrollbars, form controls (<input>, <select>, <textarea>, <button> defaults), date and color pickers, dropdown menus, the text caret in inputs, and the resize handles on textareas. Declare color-scheme: light dark on the root and the browser knows the page supports both themes, so it picks the right native chrome for whichever mode is active. Skip it and every one of those native elements stays stark white in dark mode — the scrollbar reads as a bright vertical stripe on the side of your dark page, native <select> dropdowns pop open with white backgrounds, form inputs have white fills, and the text caret becomes hard to see. It looks broken even though your own CSS is perfect. This is the single most-skipped line in dark-mode implementations, and it accounts for the majority of ‘my dark mode looks unpolished’ reports. Beyond the visual fix, color-scheme also tells assistive technology and OS integrations (like macOS's Reader View, iOS's Reader mode, some browser extensions) that the page has explicit theme support — they use the property to decide whether to inject their own theme override. Every demo in this collection sets color-scheme: Demo 01 declares light dark so the browser picks based on the OS; Demo 02 flips between light and dark as the user toggles; Demo 05 uses it as the entire switch mechanism for the light-dark() function. For production, always set it on the root element (<html>) so native chrome flips globally, not just inside your themed component subtree. In frameworks, set it in the same head script that sets data-theme so both flip on the same paint frame — Demo 03 documents the exact pattern.How do I derive dark variants from a single accent color using oklch() relative color syntax?
oklch(from var(--primary) calc(l - 0.4) calc(c * 0.7) h). That reads: ‘take the color stored in --primary, drop its lightness by 40%, reduce its chroma to 70% of the original, keep the hue unchanged.’ The result is a darker, slightly less saturated version of the primary color — perfect for a dark-mode surface tint that reads as visually connected to the accent instead of an arbitrary neutral gray. The from keyword works with every color function — rgb(from ...), hsl(from ...), oklch(from ...), oklab(from ...) — but oklch is the one you want because it operates in a perceptually-uniform color space (equal changes in l produce equal perceived changes in lightness across all hues, unlike HSL). This means you can derive a whole dark ramp from a single accent hue: oklch(from var(--accent) 0.95 calc(c * 0.3) h) for the lightest tint, 0.75 for hover fills, 0.55 for buttons, 0.35 for borders, 0.15 for backgrounds — all preserving the same underlying hue. Pro tip from Demo 05: use relative color syntax to derive the card surface from the accent so the whole component reads as a family: background: oklch(from var(--accent) 0.22 calc(c * 0.35) h). This is how you get the ‘this dark theme feels intentional’ polish that distinguishes senior work from student work. Browser support: Chrome 119+, Safari 16.4+, Firefox 128+. For older browsers, fall back to hand-picked hex values inside @supports not (color: oklch(from red l c h)). See the ProTips section below for the exact one-liner and every other pro technique surface this collection ships.How do these dark-mode systems compare to next-themes, theme-ui, styled-components, or Tailwind's dark mode?
next-themes is 3-4KB gzip, theme-ui is 15KB+, styled-components is 12KB+ just for its theme provider layer. Tailwind's darkMode: 'class' strategy is free at runtime but produces one extra CSS class per themable rule (doubling the CSS output for every darkened selector). Every implementation in this collection is either 0 KB (Demo 01 is pure CSS) or under 1 KB uncompressed of vanilla JavaScript (Demos 02-04) — no dependencies, no ThemeProvider wrapper, no build-time class generation. Framework lock-in — next-themes requires Next.js. theme-ui requires React + a specific JSX pragma. styled-components requires React + its babel plugin. Tailwind requires the tailwind build step. These 5 demos drop into any stack (Astro, Next.js, SvelteKit, Vue, Solid, plain HTML, Rails ERB, Django templates, WordPress themes) with zero adaptation. Feature parity — the demos in this collection cover every feature next-themes ships: OS sync, manual toggle, 3-way (System / Light / Dark), localStorage persistence, no-flash on reload. What they add that next-themes doesn't: the native light-dark() function (Demo 05), which next-themes can't use because it swaps classes rather than color-scheme. What they lack: React-idiomatic hooks (useTheme()) — but you can add your own useTheme() in 10 lines of React over these vanilla patterns. Accessibility floor — every demo here ships WCAG 2.2 AA: aria-pressed on toggle buttons, aria-checked on 3-way radios, aria-current on active states, real semantic HTML (<button>, <input type="radio">, <label>), 44×44px minimum touch targets, focus-visible rings that survive both themes, prefers-reduced-motion guards on every transition. next-themes is unopinionated about the button UI so it depends on what you build; theme-ui's built-in switch is accessible; Tailwind's official examples ship aria-pressed. If you're already deep in one of those ecosystems, stay there. If you're framework-agnostic, or if you want to understand what a theme system actually is (not what the wrapper hides from you), copy these five demos and ship.Are these dark mode systems free to use commercially, and do they work in React, Vue, Svelte, Next.js, Astro, and SvelteKit?
className instead of class), a Vue single-file component's template, a Svelte component, an Astro island, or a SvelteKit page — the CSS ships as a scoped module, an inline <style> block, or a Tailwind arbitrary-value paste. React — use useEffect to attach the toggle handler (or just leave the vanilla IIFE alone; it's idempotent). React StrictMode double-mount is safe. Vue 3 — use onMounted to attach the click handler; the data-theme attribute swap is pure DOM. Svelte / SvelteKit — use onMount; the head script (Demo 03) goes in src/app.html. Astro — the head script goes in your Layout component's <head>; the toggle button lives in an island. Next.js App Router — the head script goes in app/layout.tsx's <head> as a raw string via <script dangerouslySetInnerHTML={{ __html: '...' }} /> or via <Script strategy="beforeInteractive">; the toggle button is a 'use client' component. Every demo has been mentally tested at 320px / 600px / 1200px viewport widths per this site's responsive-first contract — the code you copy is the code that ships to your users at every width. Content-Security-Policy: the head script (Demo 03) requires 'unsafe-inline' in script-src, or a nonce. Add nonce="{cspNonce}" to the script tag in Next.js / Astro / SvelteKit where nonces are supported. All other demos work under strict CSP because their JS runs from external files that the CSP allowlist can permit.Related collections
30 CSS Badges
30 hand-coded CSS badges for status indicators, notifications, membership tiers, live-data displays, and SEO / DevOps / financial dashboards — upload progress, typing indicator, transit line status, Core Web Vitals, ECG heartbeat, CI/CD build pipeline, countdown ring, live price ticker, keycap shortcut, wax seal, conference lanyard, and holographic collectibles. Copy-paste HTML and CSS, WCAG 2.2 accessible, MIT licensed.
20 CSS Banners & Alerts
20 hand-coded CSS banner and alert bars for e-commerce storefronts, SaaS dashboards, marketing sites, and compliance-bound products. Covers GDPR cookie consent with a real preferences panel, promo bars with countdown timers, free-shipping threshold progress, app-install smart banners, maintenance and incident status bars, newsletter signup bars, age verification gates, limited-stock urgency banners, live event announcements, browser upgrade notices, geo and currency switchers, sticky top announcement bars, bottom cookie bars, inline form validation alerts, icon-aligned alert banners, left-border accent alerts, diagonal stripe promos, full-width hero banners, animated gradient border alerts, and a text-wrapping laboratory that shows five strategies for the unbreakable string that breaks more banners than anything else. Every bar is scoped under a .ba-NN prefix for no-collision pasting, guards prefers-reduced-motion, carries the correct aria-live and role semantics, and ports unchanged to React, Vue, Svelte, Astro, Next.js and Tailwind.
25 CSS Blockquotes
25 CSS blockquotes for article pull quotes, testimonial cards, docs callout admonitions, statement banners, and social-post embeds. Covers giant decorative quotation marks, minimalist hairline and classic left borders, animated gradient and glowing neon borders, glassmorphism and aurora backgrounds, brutalist cards, speech bubbles, thick accent rails, highlighted mark text, newspaper multi-column layouts, an expandable read-more disclosure, a scroll-snap testimonial slider, and one-click copy.




