defuss-shadcn / Guides / dark-mode
Dark Mode
Dark mode is built into the token layer. Add class="dark" to <html> and every component switches automatically - no per-component overrides needed.
On this page (7)
§How it works
The token file (default-semantic-tokens.css) defines two complete sets of color values. The :root block provides light mode values. The .dark selector overrides every color token with its dark mode counterpart.
/* Light mode (default) */:root { color-scheme: light; --background: oklch(1 0 0); /* white */ --foreground: oklch(0.145 0 0); /* near-black */ --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); /* ... all other tokens ... */}/* Dark mode - every token flips */.dark { color-scheme: dark; --background: oklch(0.145 0 0); /* near-black */ --foreground: oklch(0.985 0 0); /* near-white */ --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); /* ... all other tokens ... */}Components reference tokens with var(--background), var(--foreground), etc. When the .dark class is present on <html>, the CSS cascade replaces every value. No component CSS needs to change.
<!-- Light mode --><html lang="en"><!-- Dark mode - just add the class --><html lang="en" class="dark">§Token pairs
Every surface token has a matching foreground token. When the surface color shifts for dark mode, the foreground shifts to maintain contrast.
| Token | Light | Dark |
|---|---|---|
--background | oklch(1 0 0) | oklch(0.145 0 0) |
--foreground | oklch(0.145 0 0) | oklch(0.985 0 0) |
--primary | oklch(0.205 0 0) | oklch(0.922 0 0) |
--primary-foreground | oklch(0.985 0 0) | oklch(0.205 0 0) |
--card | oklch(1 0 0) | oklch(0.205 0 0) |
--muted | oklch(0.970 0 0) | oklch(0.269 0 0) |
--muted-foreground | oklch(0.556 0 0) | oklch(0.708 0 0) |
--border | oklch(0.922 0 0) | oklch(0.275 0 0) |
--destructive | oklch(0.577 0.245 27.325) | oklch(0.704 0.191 22.216) |
§Color scheme declaration
The <meta name="color-scheme"> tag and the color-scheme CSS property tell the browser which color schemes your page supports. This affects native UI elements like scrollbars, form controls, and the default background.
<!-- In <head> - tells browser both schemes are supported --><meta name="color-scheme" content="light dark">:root { color-scheme: light; }.dark { color-scheme: dark; }§System preference detection
Use prefers-color-scheme to detect the user's OS-level dark mode preference. The JavaScript matchMedia API provides both initial detection and a live listener for real-time changes.
// after load, df$ (installed by core.js / all.js) does the DOM workconst darkMQ = matchMedia('(prefers-color-scheme: dark)');// Follow OS changes in real time -// unless the user picked a theme by handdarkMQ.addEventListener('change', (e) => { if (localStorage.getItem('defuss-shadcn-theme')) return; df$('html') .toggleClass('dark', e.matches) .css('color-scheme', e.matches ? 'dark' : 'light');});§Persistence
When a user explicitly toggles dark mode, save their choice to localStorage. On page load, check localStorage first - if no preference is saved, fall back to the OS setting.
// Synchronous in <head> (no defer, no module): it must run before the// first paint, so before any library - plain DOM on purpose, not df$const saved = localStorage.getItem('defuss-shadcn-theme');const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;if (saved === 'dark' || (!saved && prefersDark)) { document.documentElement.classList.add('dark'); document.documentElement.style.colorScheme = 'dark';}Key detail: This script runs synchronously in <head> (no defer or type="module") so the dark class is applied before the browser paints. This prevents a flash of light theme on page load.
§Adding a toggle button
A minimal dark mode toggle in three parts: the initialization script in <head>, the toggle button, and the click handler.
<script> // Before paint - before df$ exists: saved preference, else the OS default const saved = localStorage.getItem('defuss-shadcn-theme'); const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches; if (saved === 'dark' || (!saved && prefersDark)) { document.documentElement.classList.add('dark'); document.documentElement.style.colorScheme = 'dark'; }</script><button id="theme-toggle" class="btn" data-size="icon" aria-label="Toggle dark mode"> <!-- Sun icon (shown in dark mode) --> <i data-lucide="sun" class="theme-icon-sun"></i> <!-- Moon icon (shown in light mode) --> <i data-lucide="moon" class="theme-icon-moon"></i></button><style> /* the icons follow the class - no script touches them */ .theme-icon-sun { display: none; } .dark .theme-icon-sun { display: inline; } .dark .theme-icon-moon { display: none; }</style>// df$ - the query runtime core installed - reads and writes the DOMdf$('#theme-toggle').on('click', () => { const dark = !df$('html').hasClass('dark'); df$('html') .toggleClass('dark', dark) .css('color-scheme', dark ? 'dark' : 'light'); localStorage.setItem('defuss-shadcn-theme', dark ? 'dark' : 'light');});§Custom themes
Dark mode is just one dimension of theming. The full token system supports drop-in color themes from tweakcn.com - each theme includes both light and dark values. See the Theming page for details on custom themes, token structure, and the OKLCH color space.
Comments, ideas or improvements? Edit this page's source on GitHub