foundation

Themes

One default theme with light and dark modes today, full palette re-theming via preset options, and density variants planned.

Pindoba ships a default theme with light and dark modes — and the entire palette is already re-themeable: the preset accepts your own color definitions and re-derives every semantic token from them.

Current State

neutralfrom gray
filled soft
primaryfrom blue
filled soft
successfrom green
filled soft
warningfrom orange
filled soft
dangerfrom red
filled soft
---
import Panel from "@pindoba/astro-panel";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";

// The five semantic groups the default theme derives from its five base
// palettes (gray, blue, green, orange, red).
const groups = [
  { tone: "neutral", palette: "gray" },
  { tone: "primary", palette: "blue" },
  { tone: "success", palette: "green" },
  { tone: "warning", palette: "orange" },
  { tone: "danger", palette: "red" },
] as const;

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

<div class={grid}>
  {groups.map((g) => (
    <Panel emphasis="secondary" tone={g.tone} padding="md" radius="lg">
      <div class={stack}>
        <span class={name}>{g.tone}</span>
        <span class={mono}>from {g.palette}</span>
        <div class={row}>
          <Badge emphasis="primary" tone={g.tone}>
            filled
          </Badge>
          <Badge emphasis="secondary" tone={g.tone}>
            soft
          </Badge>
        </div>
        <div>
          <Button emphasis="primary" tone={g.tone} size="sm">
            Action
          </Button>
        </div>
      </div>
    </Panel>
  ))}
</div>

Color modes are controlled via data-theme-mode on the root element. All semantic tokens resolve to the correct palette automatically:

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

Without the attribute, pages render dark (semantic tokens use base = dark, _light as the override) — see Theme Modes for the bootstrap that applies the user/OS preference before first paint.

Components don’t need to know which mode is active:

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

<div
  className={css({
    backgroundColor: "neutral.surface.peak",
    color: "neutral.text.bold",
  })}
>
  Works in both modes without conditional logic
</div>;

Custom Palettes (shipped)

The preset accepts your own W3C DTCG token files — the same format the devtools export — and re-derives the entire semantic layer from them: text tiers are re-solved for contrast (WCAG + APCA floors), surface bands, borders, and the accent band all regenerate from your ramps’ generator parameters.

// panda.config.ts
import { preset } from "@pindoba/panda-preset";
import light from "./tokens/light.json";
import dark from "./tokens/dark.json";

export default defineConfig({
  presets: [preset({ baseColors: { light, dark } })],
});

Families you don’t override fall back to the bundled palette, so a brand re-theme can be as small as replacing blue. The devtools are the editor for these files: tune anchors, offsets, and semantic mappings live, then export light.json / dark.json.

Theme Architecture

Themes are built on three layers:

1. W3C Design Tokens

Color ramps are defined in W3C DTCG format (light.json and dark.json) and converted to Panda CSS tokens at build time. Five base palettes (gray, blue, green, orange, red) generate the raw values, and five semantic groups (neutral, primary, success, warning, danger) reference them through structured sub-tokens.

2. Component Recipes

Style recipes reference semantic tokens, not raw values. When a theme changes the underlying token values, all components update automatically:

// Recipes use semantic tokens
root: {
  backgroundColor: "colorPalette.accent.surface.peak",
  color: "colorPalette.text.contrast",
}

3. Global Styles

Page-level defaults (body background, text color, text selection, scrollbars, focus rings) are token-driven and adapt to the active theme — including elevation: shadows.* are semantic tokens with per-mode values.

Future Themes

High Contrast

A theme for users who need enhanced visibility:

  • Higher contrast ratios meeting WCAG AAA
  • Bolder borders and stronger focus indicators
  • Reduced transparency — alpha colors replaced with solid alternatives

Other Planned Themes

  • Warm — warmer neutral tones
  • Cool — cooler blue-tinted neutrals

Density Variants (planned)

Independent of color themes, Pindoba will support density variants that control spacing and sizing — relaxed (content-focused: larger padding, 48px+ touch targets) and compact (data-dense: tighter padding, 24px minimum targets). Themes and density will be independent axes; the data-density attribute does not exist yet.

Best Practices

  • Use semantic tokens — never hardcode hex values. Use primary.text.bold so your UI adapts to future themes
  • Need a raw hue? Use the theme-aware aliases (blue.500) rather than the mode-pinned ramps (blue.light.500) — aliases flip with the mode
  • Use .text.contrast on filled accent surfaces — it passes on every accent-band stop by construction
  • Use spacing tokens — padding: "md" will scale with density variants
  • Avoid manual _light / _dark overrides — colors and shadows are both token-covered
  • Test in both modes — if it looks broken in dark mode, it’ll look broken in future themes too