design token

Colors

Color system with five base palettes and semantic tokens that adapt to light and dark themes.

The Pindoba color system uses custom color ramps stored as W3C Design Tokens (DTCG format) with automatic light and dark mode adaptation. Five base palettes feed into five semantic color groups.

Base Ramps

Five base color families, each with 11 shades (50–950). Each family has independent light and dark ramps; semantic tokens pick the right one automatically. The swatches below are read straight from the preset’s token files, so they always match what ships. Hover a swatch for its hex.

Each shade is reachable three ways:

  • blue.500: theme-aware. It resolves to the light ramp in light mode and to the dark ramp in dark mode.
  • blue.light.500 / blue.dark.500: pinned to one mode, for when you deliberately want the same color in both.
  • Semantic tokens (below): the preferred route, because they describe intent rather than a shade.
blue · primary
light · blue.light.*
50
100
200
300
400
500
600
700
800
900
950
dark · blue.dark.*
50
100
200
300
400
500
600
700
800
900
950
gray · neutral
light · gray.light.*
50
100
200
300
400
500
600
700
800
900
950
dark · gray.dark.*
50
100
200
300
400
500
600
700
800
900
950
green · success
light · green.light.*
50
100
200
300
400
500
600
700
800
900
950
dark · green.dark.*
50
100
200
300
400
500
600
700
800
900
950
orange · warning
light · orange.light.*
50
100
200
300
400
500
600
700
800
900
950
dark · orange.dark.*
50
100
200
300
400
500
600
700
800
900
950
red · danger
light · red.light.*
50
100
200
300
400
500
600
700
800
900
950
dark · red.dark.*
50
100
200
300
400
500
600
700
800
900
950

Semantic Groups

Every group exposes the same shape: a solid fill, text steps, borders, a five-step surface micro-ramp and a five-step accent band. Reach for these by intent rather than raw shade.

Pick a group to see its whole family live. The readability matrix paints every text token on every surface and measures the WCAG contrast from the colors the browser actually renders. Toggle the theme to watch the numbers change.

measured in light mode

Terrain · primary.surface.* peak sits highest, ground is the floor

Aa peak
Aa hill
Aa base
Aa valley
Aa ground

Accent band · primary.accent.surface.* solid fills: rest → hover → press

Aa peak
Aa hill
Aa base
Aa valley
Aa ground

Text · on primary.surface.peak WCAG ratio measured live

headings, key figures primary.text.bold measuring…
body copy primary.text measuring…
helper text, captions primary.text.muted measuring…
links, emphasis primary.text.accent measuring…

Borders · primary.border.* alpha blends of the text color, plus a solid accent

border.muted separators
border inputs, cards
border.bold emphasis
border.accent focus, selection

Readability · every text token on every surface struck-through ratios fall below 4.5:1

peakhillbasevalleyground
text.bold
Aa –
Aa –
Aa –
Aa –
Aa –
text
Aa –
Aa –
Aa –
Aa –
Aa –
text.muted
Aa –
Aa –
Aa –
Aa –
Aa –
text.accent
Aa –
Aa –
Aa –
Aa –
Aa –
contrast.bold
Aa –
Aa –
Aa –
Aa –
Aa –
contrast
Aa –
Aa –
Aa –
Aa –
Aa –
contrast.muted
Aa –
Aa –
Aa –
Aa –
Aa –
contrast.accent
Aa –
Aa –
Aa –
Aa –
Aa –

What each token in a group is for:

  • <group>: the solid fill, a reference to the family’s shade 500.
  • <group>.surface.*: the terrain micro-ramp (peak, hill, base, valley, ground).
  • <group>.accent.surface.*: the solid accent band, with the same five stops, for filled and interactive surfaces.
  • <group>.text, .text.bold, .text.muted, .text.accent: text on the group’s surfaces and on neutral surfaces.
  • <group>.text.contrast.*: text on the solid fill and the accent band.
  • <group>.border, .border.muted, .border.bold: the text color at 30%, 15% and 65% alpha.
  • <group>.border.accent: a solid border (the family’s shade 500) for focus and selection.
  • <group>.border.contrast.*: borders on the solid fill, alpha blends of text.contrast.accent.

The full reference grids:

palette · neutral
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · primary
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · success
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · warning
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · danger
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent

Color families

A color family is a palette with no status meaning. It exposes the same token shape as a semantic group under its own name (teal.surface.peak, teal.text.contrast, …) and is accepted by the tone prop of every component except Button — for labels, people, categories, chart series and decoration. Use a semantic group, never a family, to communicate status.

Four chromatic families — teal, cyan, violet, pink — and four tinted neutrals — slate (cool), stone (warm), sage (green), mauve (plum). Each chromatic family clears a fixed perceptual distance from every other family with normal vision and under the two common red–green deficiencies; the yellow–lime and indigo gaps on the hue wheel do not, which is why there is no amber or lime (they collapse onto orange and green) and no indigo (onto blue). The neutrals are tellable from gray and from each other with normal vision; their hue cue is deliberately faint. The rule is enforced by tone-distinguishability.test.ts in @pindoba/panda-preset.

The palette also ships families that are not tone values: amber, yellow, lime, emerald, indigo and rose, plus the olive neutral. They fill the hue wheel for theming (a palette’s primary, the Appearance block’s colors), where a family stands alone and never needs telling apart from a status hue. They expose their base ramp (amber.500, …) only. Amber, yellow and lime solve their own lightness around a bright fill with dark text (#f4a500, #edc812, #9ad335 in light mode), so they look like amber, yellow and lime and their labels reach APCA Lc 60.

palette · teal
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · cyan
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · violet
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · pink
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · slate
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · stone
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · sage
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent
palette · mauve
surface
surface.peak
surface.hill
surface.base
surface.valley
surface.ground
accent surface · text.contrast
accent.surface.peak
accent.surface.hill
accent.surface.base
accent.surface.valley
accent.surface.ground
text
Aa Bb 01text
Aa Bb 01text.bold
Aa Bb 01text.muted
Aa Bb 01text.accent
border
border
border.muted
border.bold
border.accent

Dynamic tone

tone="dynamic" is a family generated at runtime from a seed color: a user-picked label color, a tenant’s brand color, a color hashed from a name. @pindoba/core-tone runs the same generator the preset uses and writes the result as CSS variables on a scope element. Every tone="dynamic" inside that element recolors in both modes, and no new CSS is generated.

---
import { toneStyle } from "@pindoba/core-tone";
---

<!-- SSR: the variables are inlined, no hydration -->
<div style={toneStyle(tenant.brandColor)}>
  <Card tone="dynamic">…</Card>
</div>
import { applyTone } from "@pindoba/core-tone";

// Runtime: replaces the scope's tone; returns the generator's diagnostics.
const { valid, kind, unreachableTargets } = applyTone(scope, "#7c3aed");

toneVars(seed) returns the same variables as an object, for a React or Solid style prop.

  • Only the seed’s hue and chroma are used. Lightness follows the theme’s curve, so text clears WCAG AA on every surface step for any seed. A near-gray seed (chroma ≤ 0.03) becomes a tinted neutral.
  • Unset, it is neutral. Without a scope, or with a seed that is not a color, tone="dynamic" renders exactly like tone="neutral".
  • Custom themes. Seeds are generated against the bundled palette by default. A theme with retuned families passes toneStyle(seed, { basis: toneBasisFromW3c(light, dark) }) (from @pindoba/panda-preset).
  • Diagnostics. unreachableTargets lists the tokens whose design target (APCA, or a bold step) the generator had to clamp for that seed. The values still pass AA, so an app can accept the seed or nudge the user toward another.

Try it in the dynamic tone lab.

Assigning families to roles

A theme maps each semantic role to a family (primary → blue, neutral → gray, …), editable in the devtools. Two rules apply:

  • neutral must be a low-chroma family: gray or a tinted neutral (chroma ≤ 0.03). It builds every surface and every family’s text is solved against it, so @pindoba/panda-preset refuses a theme that maps it to a chromatic family.
  • Two roles may share a family, but they then look the same: primary and warning both orange makes a call to action read like a warning. The devtools flag it.

How Colors Are Generated

Color ramps are generated in the OKLCH color space using a contrast-based approach:

  • Shade 500 is the anchor — the user-defined color for each family (lightness, chroma, hue)
  • Lighter shades (50–400) progressively reduce WCAG contrast toward 1:1 against the mode background
  • Darker shades (600–950) progressively increase contrast toward 21:1
  • A binary search finds the exact lightness value for each shade that meets its target contrast ratio

Text tokens are contrast-solved against the worst-case surface of two ramps: the neutral ramp and the family’s own surface ramp (including any tuned middle stop). Each target is a dual floor, a WCAG ratio and an APCA Lc minimum, and the stricter one wins. WCAG usually binds in light mode and APCA in dark mode, which keeps dark-mode text from reading thinner than light.

TokenWCAGAPCA LcSolved against
text7:175neutral + own surfaces
text.bold10:190neutral + own surfaces
text.muted4.555neutral + own surfaces
text.accent4.560neutral + own surfaces (links)
text.contrast4.560the family’s filled shade 500
text.contrast.bold7:175shade 500 (clamps to the extreme)
text.contrast.muted4.550shade 500
text.contrast.accent4.550shade 500, tinted with the family hue

Two consequences follow:

  • Pair text with its own palette. success.text.muted is guaranteed on success.surface.* and neutral.surface.*, but neutral.text.muted is not solved for success.surface.* (it measures ≈4:1 in light mode). On a coloured surface, use colorPalette.text.* or panel.text.* so the text follows the surface.
  • Contrast text is for fills. text.contrast.* is solved against shade 500 and the accent band. A transparent or translucent “primary” container sits on the page, so it uses the palette’s own text.*. Panel does this for you.

text.contrast.bold cannot reach 7:1 against a mid-tone shade 500, so it clamps to the strongest value possible. The generator reports clamps like this in meta.unreachableTargets.

Surfaces are a 5-stop micro-ramp between shade 50 and shade 100, named as a terrain ladder that reads as structural depth (never as shadow elevation — shadows are a separate axis that composes with any surface):

StopValueRole
surface.peakpegged to shade 50 (light) / 100 (dark)the topmost color: cards, reading sheets, and — with a shadow — popovers and menus
surface.hill25% between the endpointsintermediate raised tier
surface.basemidpointthe page floor (body); recessed regions and selected rows inside a peak card
surface.valley75% between the endpointsrails and wells cut into the page; insets and disabled fills inside a card
surface.groundpegged to shade 100 (light) / 50 (dark)the deepest wells and window chrome — not a floor, nothing can sink below it

The endpoints are pegged — byte-identical to their ramp shades and emitted as shade refs, so overriding a base ramp propagates to both ends of the band. Only the three middle stops accept generator surface offsets; the endpoints are tuned through the shade curve itself.

The accent band (accent.surface.*, interactive fills rest → hover → active → pressed) follows the same peg rule: accent.surface.peak = shade 500 and accent.surface.ground = the adjacent anchor shade (600 light / 400 dark, in the direction away from text.contrast.*), both as shade refs — one ramp interval quartered, mirroring the structural band. When a pegged dark ground sits below a 2:1 floor against the page, the generator raises an accentPresenceTooLow flag (diagnostic only) instead of moving the band.

Fill presence. The devtools warn when a resting fill doesn’t read as a shape against the page: below APCA Lc 15, APCA’s floor for discernible non-text (FILL_MIN_PRESENCE_LC). Hover, active and pressed are states of a control already found, so they aren’t held to it. APCA, not a WCAG 2 ratio, because the ratio is polarity-biased: a bright yellow fill at 1.6:1 on a white page (Lc 27) is more visible than a dark-mode fill at 2.1:1 (Lc 12). It is advisory: a labelled control is identified by its label, and text contrast is never relaxed.

Migrating from the old names: surface.soft → surface.peak, surface.step.1 → surface.hill, surface.step.2 → surface.base, surface.step.3 → surface.valley, surface.deep → surface.ground (same mapping under accent.surface.*).

Per-mode palettes: light and dark modes have independent color family inputs. A lightness reference family (default: blue) shares its solved lightness curve with all other families, keeping them visually consistent. Neutral (gray) always uses its own independent lightness curve.

Contrast Colors

Two fixed contrast values are available for cases where you need guaranteed light or dark:

contrast.darkest; // #181818
contrast.lightest; // #f9fbff

Focus Ring

focusRing is the focus indicator color. It is near-black (contrast.darkest) in light mode and near-white (contrast.lightest) in dark mode, so it reads on any surface. The pindobaOutline utility uses it. Reach for the utility rather than the raw token:

css({ _focusVisible: { pindobaOutline: true } });
// or, for a custom indicator:
css({ _focusVisible: { outlineColor: "focusRing" } });

Panel-Scoped Tokens

panel.text, panel.text.bold, panel.text.muted, panel.text.accent and panel.border / .bold / .muted / .accent resolve to whatever the nearest Panel publishes. On a filled primary panel they become the contrast ramp; on any other panel they are the panel’s palette text. Outside a Panel they fall back to colorPalette.*. Use them for content you place inside cards, dialogs and other surfaces, so it stays legible whatever surface it lands on. Surfaces & Elevation shows how a Panel resolves them.

Chart Colors

chart.1 to chart.13 are an ordered categorical set for data visualization. They are references to the theme-aware family aliases, so they flip with the mode. The five built-in families come first, then the color families, so a chart with more than five series stops reusing the status hues:

TokenResolves to
chart.1blue.500
chart.2green.500
chart.3orange.500
chart.4red.500
chart.5gray.500
chart.6teal.500
chart.7cyan.500
chart.8violet.500
chart.9pink.500
chart.10slate.500
chart.11stone.500
chart.12sage.500
chart.13mauve.500

Assign them in order, one per series, and don’t reuse them to mean status: chart.4 is a series color, not an error.

Usage

With Panda CSS

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

// Semantic surface and text
<div className={css({
  backgroundColor: "neutral.surface.peak",
  color: "neutral.text.bold",
})}>
  Card content
</div>

// Solid fill button — interactive states step through the accent band
// (rest = accent.surface.peak = shade 500; the same text color passes on
// every band stop by construction)
<button className={css({
  backgroundColor: "primary.accent.surface.peak",
  color: "primary.text.contrast",
  _hover: { backgroundColor: "primary.accent.surface.hill" },
  _active: { backgroundColor: "primary.accent.surface.base" },
})}>
  Submit
</button>

// Subtle surface button
<button className={css({
  backgroundColor: "primary.surface.peak",
  color: "primary.text",
  _hover: { backgroundColor: "primary.surface.base" },
  _active: { backgroundColor: "primary.surface.ground" },
})}>
  Secondary action
</button>

// Status tone
<div className={css({
  backgroundColor: "danger.surface.peak",
  color: "danger.text.bold",
  borderWidth: "1px",
  borderColor: "danger.border.muted",
})}>
  Error message
</div>

With colorPalette

The colorPalette utility lets components switch between semantic groups without duplicating styles:

<div className={css({ colorPalette: "success" })}>
  <span className={css({ color: "colorPalette.text.bold" })}>
    Status text
  </span>
</div>

<div className={css({ colorPalette: "danger" })}>
  <span className={css({ color: "colorPalette.text.bold" })}>
    Error text
  </span>
</div>

Best Practices

  • Use semantic tokens (primary.surface.peak, neutral.text.bold) rather than raw base colors (blue.light.500)
  • Use .text.contrast for text on solid-colored backgrounds (primary, primary.accent.surface.*) — it passes on every accent-band stop by construction
  • For interactive solid fills, step through the accent band: rest accent.surface.peak, hover accent.surface.hill, active accent.surface.base. There are no dedicated hover/active color tokens: primary.hover, colorPalette.active and the like do not exist. The interactiveStates pattern’s primary preset still names colorPalette.hover / colorPalette.active, which resolve to nothing, so pass bgHover / bgActive explicitly when you use it. Panel’s interactive prop does the stepping for you.
  • Use .text.muted for helper text and secondary content
  • Use .border.muted for subtle separators and .border.bold for emphasis
  • The page stands on surface.base; put cards and reading content on surface.peak above it, and sink wells toward surface.ground below it. See The page floor. All of them adapt to the active theme mode
  • Borders use alpha-blended variants of the text color: .border (30%), .border.muted (15%), .border.bold (65%). .border.accent is solid (shade 500).
  • Inside a Panel or Card, use panel.text.* / panel.border.* so content follows the surface.