Dark mode

Class-based toggling, persisted preference, system-preference fallback, and dark-aware tokens.

Overview

Byteion uses semantic CSS variables for every color, surface, and border token. All components reference these variables — never raw hex values. When .dark is applied to <html>, the variable values swap from light to dark and every component re-renders automatically. There is no separate dark theme to maintain, no duplicated component CSS, and no dark: variant needed when using semantic tokens.

Two activation strategies are supported:

.dark class (recommended)

Add class="dark" to <html> to toggle the entire page. This is the standard app-level strategy — it works with Tailwind's darkMode: 'class', persists cleanly in localStorage, and can be set before first paint to eliminate flash.

[data-theme="dark"]

Use [data-theme="dark"] on any container for scoped dark mode — ideal for embedded widgets, documentation previews, theme demos, or design tool mockups. The Tailwind preset recognizes both selectors.

How it works

1. Variable layers

Light defaults on :root + [data-theme="light"], dark overrides on .dark + [data-theme="dark"]. Brand themes live on [data-theme="<brand>"] and can opt into dark via [data-mode="dark"].

2. Toggle selector

Add or remove class="dark" on <html>. The @custom-variant dark declaration in v4 (or darkMode: ['class', '[data-theme="dark"]'] in v3) ensures dark: utilities fire under either selector.

3. Tokens re-resolve

Every bg-background, text-foreground, and border-border reads the variable at render time. Change the selector, and every class resolves to its dark value instantly — no component code changes.

The dark block in variables.css only overrides color and surface variables. Shadows, motion, and radius tokens are not redeclared — they remain identical across modes. This keeps the dark override minimal and predictable:

:root, [data-theme="light"] {
  --background: 0 0% 100%;
  --foreground: 240 10% 3.9%;
  --card: 0 0% 100%;
  --border: 240 5.9% 90%;
  --muted-foreground: 240 3.8% 43%;
  /* … 50+ variables for colors, shadows, motion, z-index, radius */
}

.dark, [data-theme="dark"] {
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --card: 240 6% 10%;
  --border: 240 3.7% 15.9%;
  --muted-foreground: 240 5% 64.9%;
  /* Only color/surface variables are overridden */
}

Tailwind configuration

Dark mode works differently depending on whether you use Tailwind v3 or v4 with Byteion.

Tailwind v3

Add Byteion's preset to your tailwind.config.js. The preset already sets darkMode: ['class', '[data-theme="dark"]'] so both selectors activate dark: utilities:

module.exports = {
  darkMode: ['class', '[data-theme="dark"]'],
  presets: [require('@byteion/core/preset')],
  content: ['./src/**/*.{html,js,jsx,tsx,vue}'],
};

Then import the token CSS in your entry stylesheet:

@import "@byteion/core/theme/generated/tokens.css";
@import "@byteion/core/theme/variables.css";

Tailwind v4

Import Byteion's v4 entry in your main CSS file. It includes the token layer, semantic variables, and the @custom-variant dark declaration — no JavaScript config needed:

@import "tailwindcss";
@import "@byteion/core";

The @custom-variant dark declaration in Byteion's theme/index.css matches both .dark and [data-theme="dark"]. This is equivalent to v3's darkMode: ['class', '[data-theme="dark"]'] but expressed entirely in CSS.

Toggle script

Persist the user's choice in localStorage and apply it before paint to avoid a flash of light content:

<!-- Inline in <head>, BEFORE any stylesheet -->
<script>
  (function () {
    const stored = localStorage.getItem('theme');
    const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
    const shouldBeDark = stored ? stored === 'dark' : prefersDark;
    document.documentElement.classList.toggle('dark', shouldBeDark);
  })();
</script>

Wire up a toggle button with accessible icon swap:

<button type="button" id="theme-toggle" aria-label="Toggle dark mode"
  class="inline-flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted">
  <i class="ti ti-moon dark:hidden"></i>
  <i class="ti ti-sun hidden dark:inline-block"></i>
</button>

<script>
  document.querySelector('#theme-toggle').addEventListener('click', () => {
    const next = document.documentElement.classList.toggle('dark') ? 'dark' : 'light';
    localStorage.setItem('theme', next);
  });
</script>

Respect system preference

The toggle script above defers to prefers-color-scheme when the user has no stored preference. To also react when the OS theme changes while the page is open:

matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    document.documentElement.classList.toggle('dark', e.matches);
  }
});

This ensures a user who switches their OS from light to dark at 2pm sees the page adapt immediately — but only if they have not explicitly chosen a mode. A stored preference always wins over the OS signal.

Scoped dark mode

Apply .dark on a container to render that subtree in dark mode regardless of the page-level setting. This is useful for embedded previews, documentation examples, theme comparison widgets, or design tool mockups:

<!-- This card renders in dark mode even if the page is light -->
<div class="dark">
  <div class="rounded-card border border-border bg-card p-lg shadow-card">
    <h3 class="text-base font-semibold text-foreground">Dark preview</h3>
    <p class="text-sm text-muted-foreground mt-sm">
      This block uses dark token values regardless of the outer theme.
    </p>
  </div>
</div>

The same applies to [data-theme="dark"] — place it on any parent element to scope dark mode to that subtree. This is especially useful in documentation sites that show light and dark variants side by side.

Dark-aware tokens

When you use semantic tokens, you never need to write a dark: variant. The token resolves to the correct value for the active mode. Brand colors like --primary stay the same across modes; only surfaces and chromes swap:

Token classLight (:root)Dark (.dark)
bg-background0 0% 100%240 10% 3.9%
text-foreground240 10% 3.9%0 0% 98%
bg-card0 0% 100%240 6% 10%
bg-muted240 4.8% 95.9%240 3.7% 15.9%
text-muted-foreground240 3.8% 43%240 5% 64.9%
border-border240 5.9% 90%240 3.7% 15.9%
ring (brand)217 91% 60%217 91% 60%

Developer tips

• Semantic tokens eliminate dark: variants. bg-background resolves to white in light mode and near-black in dark mode. You never need bg-white dark:bg-zinc-900. • The toggle script must run before paint. Inline it in <head> before any stylesheet to prevent a flash of light content (FOLC). • Stored preference beats OS signal. If the user explicitly toggled dark mode, respect that choice until they change it. Only fall back to prefers-color-scheme when there is no stored value. • Test every new screen with dark mode toggled. A component that works in light mode may have hardcoded colors or missing semantic tokens. Toggle .dark during development as part of your review workflow. • Brand colors stay constant across modes. --primary and --ring do not change in dark mode. Only surfaces, foregrounds, borders, and muted elements swap — brand colors remain visually consistent.

Do and Don't

Do

  • Use semantic tokens (bg-background, text-foreground, bg-card) instead of bg-white dark:bg-zinc-900
  • Run the toggle script in <head> before the first paint
  • Test every new screen or component with .dark toggled
  • Honour prefers-color-scheme for the initial un-persisted state
  • Use .dark on <html> for app-level toggling and [data-theme="dark"] for scoped previews

Don't

  • Hardcode bg-white or bg-zinc-900 on shared surfaces — they will break in dark mode
  • Sprinkle dark: variants when a semantic token already covers both modes
  • Set the dark class from a deferred or async script — you will get a flash of light content
  • Rely on the OS preference only without offering a manual toggle
  • Assume brand colors (--primary, --ring) change in dark mode — they stay constant; only surface colors swap

Unlock Premium Patterns

Get access to copy-paste ready Pro blocks, SaaS templates, and advanced component patterns.

Explore Pro Components