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.
Dark text on light surfaces, solved for contrast.
Light text on dark surfaces, the default without the attribute.
---
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.
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>;
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>
@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
neutral.surface.peak, primary.text.bold) — they adapt automatically_light / _dark conditions for styles that aren’t covered by tokens (e.g., mode-specific imagery) — colors AND shadows are both token-coveredlocalStorage and apply it in an inline script to avoid flickerprefers-color-scheme as the default when no preference is saved