
Pure CSS Automatic Dark Mode with prefers-color-scheme and Custom Properties (Zero JavaScript)
Published
5 hand-coded CSS dark-mode systems built on the 5-variable pattern — every UI here re-skins from exactly five custom properties (--bg, --surface, --text, --border, --accent), with muted text, hovers and glows derived from those five via color-mix() and oklch(). The five demos walk you from the zero-JS baseline up to the modern native primitive: pure CSS @media (prefers-color-scheme: dark) OS sync, a data-theme toggle with localStorage persistence, the anti-FOUC blocking head script that kills reload flash, a 3-way System / Light / Dark switcher wired to matchMedia, and the 2024 native light-dark() function that removes the media query entirely. Every demo declares color-scheme so native scrollbars, form controls and date pickers flip with the theme; every transition is guarded by prefers-reduced-motion; every color pair clears WCAG AA contrast in both themes. Scoped under .cdm-NN for no-collision pasting, prefers-reduced-motion respected, framework-agnostic.
Related10 CSS Dark Mode Toggles22 CSS Dark Mode UI22 CSS Glassmorphism

Published

Published

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

Published

<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.Published
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. |
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.
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);
}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 */
}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); }
}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; }
}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 */
);
}--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).<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.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.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.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.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.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.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.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 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 hand-coded CSS blockquote designs for SaaS testimonials, editorial pull quotes, documentation callouts, and developer engineering blogs: large decorative quotation marks with ::before / ::after glyphs, Tailwind CSS blockquote component, pure CSS callout / admonition boxes for Docusaurus / Mintlify / Nextra documentation, responsive center-aligned quote banners, modern minimalist left-border editorial style, animated CSS gradient border, classic left border, brutalist high-contrast, glassmorphism frosted card, glowing neon border, article pull quote with text-wrap, speech bubble chat-style, thick vertical accent rail, customer testimonial cards with avatar and star rating, inline highlighted text, marker highlight underline, floating drop-shadow, aurora gradient background, Twitter / X card style, multi-column newspaper editorial, IDE code-comment style, Markdown-compatible defaults, JavaScript testimonial quote slider, expandable read-more, and one-click copy button. 22 Pure CSS + 3 Light JS (slider, expand, copy). Every design scoped under .bq-NN prefix for no-collision pasting, prefers-reduced-motion guarded per WCAG 2.3.3, WCAG 2.2 AA accessible, framework-agnostic (React, Vue, Svelte, Astro, Next.js, Nuxt, Remix, SvelteKit).