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.
default-semantic-tokens.css — simplified
/* 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.
toggling dark mode
<!-- 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.
light vs dark token values (OKLCH)
| 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.
HTML meta tag
<!-- In <head> — tells browser both schemes are supported -->
<meta name="color-scheme" content="light dark">
CSS color-scheme property in tokens
: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.
detect and react to OS theme
// Check OS preference at page load
const darkMQ = window.matchMedia('(prefers-color-scheme: dark)');
if (darkMQ.matches) {
document.documentElement.classList.add('dark');
}
// React to OS changes in real time
// Skip if user has set a manual preference
darkMQ.addEventListener('change', (e) => {
if (localStorage.getItem('shadcn-html-theme')) return;
document.documentElement.classList.toggle('dark', e.matches);
document.documentElement.style.colorScheme =
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.
persistence logic — runs before first paint
// This script must be in <head> (synchronous, no defer)
// to prevent flash of wrong theme
const saved = localStorage.getItem('shadcn-html-theme');
const prefersDark = window.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.
step 1 — initialization script (in <head>)
<script>
// Apply saved preference or OS default before paint
const saved = localStorage.getItem('shadcn-html-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>
step 2 — toggle button markup
<button id="theme-toggle" class="btn"
data-size="icon" aria-label="Toggle dark mode">
<!-- Sun icon (visible in dark mode) -->
<svg id="icon-sun">...</svg>
<!-- Moon icon (visible in light mode) -->
<svg id="icon-moon" style="display:none">...</svg>
</button>
step 3 — click handler
document.getElementById('theme-toggle')
.addEventListener('click', () => {
const isDark = document.documentElement
.classList.toggle('dark');
document.documentElement.style.colorScheme =
isDark ? 'dark' : 'light';
localStorage.setItem('shadcn-html-theme',
isDark ? '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.