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)
TokenLightDark
--backgroundoklch(1 0 0)oklch(0.145 0 0)
--foregroundoklch(0.145 0 0)oklch(0.985 0 0)
--primaryoklch(0.205 0 0)oklch(0.922 0 0)
--primary-foregroundoklch(0.985 0 0)oklch(0.205 0 0)
--cardoklch(1 0 0)oklch(0.205 0 0)
--mutedoklch(0.970 0 0)oklch(0.269 0 0)
--muted-foregroundoklch(0.556 0 0)oklch(0.708 0 0)
--borderoklch(0.922 0 0)oklch(0.275 0 0)
--destructiveoklch(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.