foundation

Theme Modes

Light and dark mode support with automatic color adaptation via the data-theme-mode attribute.

All design tokens automatically adapt to light and dark modes. The active mode is controlled by a data-theme-mode attribute on a parent element.

Theme Conditions

data-theme-mode="light"

Light mode

Dark text on light surfaces, solved for contrast.

successwarningdanger
data-theme-mode="dark"

Dark mode

Light text on dark surfaces, the default without the attribute.

successwarningdanger
---
import Panel from "@pindoba/astro-panel";
import Button from "@pindoba/astro-button";
import Badge from "@pindoba/astro-badge";
import { css } from "@pindoba/styled-system/css";

// Each preview scopes `data-theme-mode` on its own element, so every semantic
// token inside re-resolves to that mode no matter which mode the page is in.
const modes = [
  {
    mode: "light",
    title: "Light mode",
    description: "Dark text on light surfaces, solved for contrast.",
  },
  {
    mode: "dark",
    title: "Dark mode",
    description:
      "Light text on dark surfaces, the default without the attribute.",
  },
] as const;

const feedbacks = ["success", "warning", "danger"] as const;

const grid = css({
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 260px), 1fr))",
  gap: "md",
});
const stack = css({ display: "flex", flexDirection: "column", gap: "sm" });
const row = css({ display: "flex", flexWrap: "wrap", gap: "xs" });
const title = css({
  fontSize: "md",
  fontWeight: "bold",
  color: "neutral.text.bold",
});
const body = css({ fontSize: "sm", color: "neutral.text" });
const muted = css({
  fontSize: "xs",
  fontFamily: "mono",
  color: "neutral.text.muted",
});
---

<div class={grid}>
  {modes.map((m) => (
    <div data-theme-mode={m.mode} data-theme-scope={m.mode}>
      <Panel emphasis="secondary" tone="neutral" padding="lg" radius="lg">
        <div class={stack}>
          <span class={muted}>data-theme-mode="{m.mode}"</span>
          <h4 class={title}>{m.title}</h4>
          <p class={body}>{m.description}</p>
          <div class={row}>
            {feedbacks.map((f) => (
              <Badge emphasis="secondary" tone={f}>
                {f}
              </Badge>
            ))}
          </div>
          <div class={row}>
            <Button emphasis="primary" size="sm">
              Primary
            </Button>
            <Button emphasis="secondary" size="sm">
              Secondary
            </Button>
          </div>
        </div>
      </Panel>
    </div>
  ))}
</div>

<div class={css({ marginTop: "md" })}>
  <div class={row}>
    <Button emphasis="secondary" size="sm" data-set-theme-mode="light">
      Switch page to light
    </Button>
    <Button emphasis="secondary" size="sm" data-set-theme-mode="dark">
      Switch page to dark
    </Button>
    <Button emphasis="ghost" size="sm" data-set-theme-mode="system">
      Follow system
    </Button>
  </div>
</div>

<script>
  import { themeModeSelectHydration } from "@pindoba/astro-theme-mode-select/runtime";
  import { setThemeMode } from "@pindoba/utils/theme-mode";

  // The dark preview can only re-resolve tokens at the root: Panda declares the
  // dark (base) variable set on `:root` and only the light override on
  // `[data-theme-mode="light"]`. So a dark preview inside a light page gets the
  // generated `:root` custom properties copied onto it, read from the
  // stylesheet rather than hardcoded.
  function rootVars(): Array<[string, string]> {
    const vars = new Map<string, string>();
    const walk = (rules: CSSRuleList) => {
      for (const rule of Array.from(rules)) {
        if (rule instanceof CSSStyleRule) {
          if (/^(:where\(:root, :host\)|:root)$/.test(rule.selectorText)) {
            for (const name of Array.from(rule.style)) {
              if (name.startsWith("--")) {
                vars.set(name, rule.style.getPropertyValue(name));
              }
            }
          }
        } else if ("cssRules" in rule) {
          walk((rule as CSSGroupingRule).cssRules);
        }
      }
    };
    for (const sheet of Array.from(document.styleSheets)) {
      try {
        walk(sheet.cssRules);
      } catch {
        // cross-origin sheet
      }
    }
    return [...vars];
  }

  const darkScopes = document.querySelectorAll<HTMLElement>(
    "[data-theme-scope='dark']",
  );
  if (darkScopes.length) {
    const vars = rootVars();
    darkScopes.forEach((el) => {
      for (const [name, value] of vars) el.style.setProperty(name, value);
    });
  }

  // Same engine the header ThemeModeSelect runs (persists "theme-mode",
  // stamps <html data-theme-mode>, broadcasts "theme-mode-change").
  function applyMode(mode: "light" | "dark" | "system") {
    const select = document.querySelector<HTMLElement>(
      "[data-theme-mode-select-config]",
    );
    const controller = select && themeModeSelectHydration.get(select);
    if (controller && "setMode" in controller) {
      (controller as { setMode(m: string): void }).setMode(mode);
    } else {
      setThemeMode(mode);
    }
  }

  document
    .querySelectorAll<HTMLElement>("[data-set-theme-mode]")
    .forEach((el) => {
      el.addEventListener("click", () => {
        const mode = el.dataset.setThemeMode;
        if (mode === "light" || mode === "dark" || mode === "system") {
          applyMode(mode);
        }
      });
    });
</script>

Both previews render the same components with the same tokens. The light one is scoped with data-theme-mode="light", which re-resolves tokens at any depth. Dark is the base value set declared on :root, so a dark island inside a light page needs those variables re-applied on the island (the demo copies them from the generated stylesheet). The buttons use the same engine as the header toggle.

Three conditions are defined in the Panda preset. Two select the mode; the third is a focus helper:

conditions: {
  light: "[data-theme-mode=light] &",
  dark:  "[data-theme-mode=dark] &",
  focusVisibleWithin: "&:has(:focus-visible, [data-focus-visible])",
}

_focusVisibleWithin matches an element when any descendant shows a keyboard focus ring (or carries data-focus-visible), so a wrapper can draw the ring on behalf of a hidden native input. It is not tied to the theme mode.

How It Works

Set the attribute on the <html> element. All semantic tokens resolve to the correct palette automatically:

<html data-theme-mode="light">
  <!-- light values -->
</html>
<html data-theme-mode="dark">
  <!-- dark values -->
</html>

Semantic tokens use base (dark) and _light overrides internally. Text tokens resolve to contrast-solved hex values, not shade references:

// How a semantic token resolves
primary.text.bold: {
  value: {
    base: "#c0dbff",        // dark mode — solved for 10:1 contrast
    _light: "#00216a",      // light mode — solved for 10:1 contrast
  }
}

Components don’t need conditional logic — tokens handle the switch:

import { css } from "@pindoba/styled-system/css";

<div
  className={css({
    backgroundColor: "neutral.surface.peak",
    color: "neutral.text.bold",
  })}
>
  Adapts to light or dark automatically
</div>;

Manual Overrides

Shadows are semantic too — boxShadow: "md" resolves to a subtle set in light mode and a stronger set in dark mode automatically, so elevation needs no conditionals. For the rare style genuinely not covered by a token (e.g. mode-specific imagery), _light and _dark conditions exist:

<div
  className={css({
    backgroundImage: "url(/hero-dark.png)",
    _light: { backgroundImage: "url(/hero-light.png)" },
  })}
>
  Mode-specific artwork
</div>

Persisting the Mode

@pindoba/utils owns this logic. For SSR documents, inject the bootstrap script so the attribute is set before first paint (without it, pages render dark — the tokens’ base condition):

---
import { themeModeBootstrapScript } from "@pindoba/utils/theme-mode";
---

<head>
  <script is:inline set:html={themeModeBootstrapScript()} />
</head>

At runtime, initThemeMode() applies saved-or-system and follows OS changes while no explicit choice is saved; setThemeMode() is the toggle handler:

import { initThemeMode, setThemeMode } from "@pindoba/utils/theme-mode";

initThemeMode(); // saved preference → OS preference, follows OS changes

// in a theme switcher:
setThemeMode("dark"); // or "light" | "system" — persists + applies

Best Practices

  • Prefer semantic tokens (neutral.surface.peak, primary.text.bold) — they adapt automatically
  • Only use _light / _dark conditions for styles that aren’t covered by tokens (e.g., mode-specific imagery) — colors AND shadows are both token-covered
  • Persist the user’s choice in localStorage and apply it in an inline script to avoid flicker
  • Respect prefers-color-scheme as the default when no preference is saved