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.
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.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.
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:
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.
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.
tone="dynamic" renders exactly like tone="neutral".toneStyle(seed, { basis: toneBasisFromW3c(light, dark) }) (from @pindoba/panda-preset).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.
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.primary and warning both orange makes a call to action read like a warning. The devtools flag it.Color ramps are generated in the OKLCH color space using a contrast-based approach:
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.
| Token | WCAG | APCA Lc | Solved against |
|---|---|---|---|
text | 7:1 | 75 | neutral + own surfaces |
text.bold | 10:1 | 90 | neutral + own surfaces |
text.muted | 4.5 | 55 | neutral + own surfaces |
text.accent | 4.5 | 60 | neutral + own surfaces (links) |
text.contrast | 4.5 | 60 | the family’s filled shade 500 |
text.contrast.bold | 7:1 | 75 | shade 500 (clamps to the extreme) |
text.contrast.muted | 4.5 | 50 | shade 500 |
text.contrast.accent | 4.5 | 50 | shade 500, tinted with the family hue |
Two consequences follow:
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.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):
| Stop | Value | Role |
|---|---|---|
surface.peak | pegged to shade 50 (light) / 100 (dark) | the topmost color: cards, reading sheets, and — with a shadow — popovers and menus |
surface.hill | 25% between the endpoints | intermediate raised tier |
surface.base | midpoint | the page floor (body); recessed regions and selected rows inside a peak card |
surface.valley | 75% between the endpoints | rails and wells cut into the page; insets and disabled fills inside a card |
surface.ground | pegged 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 underaccent.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.
Two fixed contrast values are available for cases where you need guaranteed light or dark:
contrast.darkest; // #181818
contrast.lightest; // #f9fbff
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.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.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:
| Token | Resolves to |
|---|---|
chart.1 | blue.500 |
chart.2 | green.500 |
chart.3 | orange.500 |
chart.4 | red.500 |
chart.5 | gray.500 |
chart.6 | teal.500 |
chart.7 | cyan.500 |
chart.8 | violet.500 |
chart.9 | pink.500 |
chart.10 | slate.500 |
chart.11 | stone.500 |
chart.12 | sage.500 |
chart.13 | mauve.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.
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>
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>
primary.surface.peak, neutral.text.bold) rather than raw base colors (blue.light.500).text.contrast for text on solid-colored backgrounds (primary, primary.accent.surface.*) — it passes on every accent-band stop by constructionaccent.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..text.muted for helper text and secondary content.border.muted for subtle separators and .border.bold for emphasissurface.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.border (30%), .border.muted (15%), .border.bold (65%). .border.accent is solid (shade 500).panel.text.* / panel.border.* so content follows the surface.