Data Colors and Ramps
Brand and interface colors answer questions such as “what is actionable?” and “what is dangerous?” Data colors answer different questions: “which series is this?” and “where does this value fall?” Keep those vocabularies separate. A green chart line is not a success state, and a red heatmap cell is not automatically an error.
Categorical series
Every theme carries eight stable categorical slots. The default is the
eight-color Okabe–Ito palette, emitted as --esp-color-series-1 through
--esp-color-series-8:
.revenue-line { stroke: var(--esp-color-series-1); }
.cost-line { stroke: var(--esp-color-series-2); }
.forecast-line { stroke: var(--esp-color-series-3); }
Override only the slots your product needs. Values accept every theme CSS color format and brand-anchor references:
const lightTheme = {
anchors: {
plum: '#78486A',
rose: '#B76E79',
},
dataPalette: {
series1: 'anchor:plum',
series2: 'anchor:rose',
series8: 'rgb(0 114 178)',
},
};
validateTheme simulates every resolved pair under full protanopia,
deuteranopia, and tritanopia, then compares the simulated colors in OKLab. A
likely collision is a warning, not an error. Treat it as a prompt to add
redundant coding: direct labels, different point shapes, solid versus dashed
lines, or patterns. No palette makes color alone a sufficient data label.
espalier theme check promotes the same findings into scheme-scoped
data-palette-cvd-collision lints. They remain heuristic advice, but the named
lint makes the command exit non-zero so a repository that adopts theme check as
a CI gate cannot ship a known collision silently. Its stable JSON also records
all eight resolved slots per scheme, including resolved anchor references.
When reproducing a suspected pair, keep both colors in different slots. For
example, put rose in series2 while the retained green remains in series4.
Replacing the green slot with rose removes the rose/green pair and therefore
cannot exercise that collision, even though the resulting palette may have a
different collision elsewhere.
Sequential ramps
Use a sequential ramp for ordered values with one direction: low to high,
light to dark, less to more. generateSequentialRamp keeps one hue, produces
strictly decreasing OKLCH lightness, and reduces chroma only when necessary to
stay inside sRGB:
import { generateSequentialRamp } from '@taprootio/espalier/shared/data-colors';
const occupancy = generateSequentialRamp('#78486A', {
steps: 7,
lightnessStart: 0.95,
lightnessEnd: 0.25,
});
Diverging ramps
Use a diverging ramp when values depart in two meaningful directions from a center: negative/positive variance, below/above target, or cooling/warming. An odd step count guarantees a real neutral midpoint:
import { generateDivergingRamp } from '@taprootio/espalier/shared/data-colors';
const variance = generateDivergingRamp('#2166AC', '#B2182B', {
steps: 7,
neutral: '#FFF8E7',
});
Both generators accept opaque CSS colors and return serialized, sRGB-gamut-
mapped oklch() strings. Step counts range from 3 through 11; diverging counts
must be odd.
Declare ramps in a theme
Declare a ramp when CSS consumers should inherit it with the active theme. Sources may be concrete colors or anchors:
const theme = {
anchors: {
blue: '#2166AC',
red: '#B2182B',
paper: '#FFF8E7',
},
dataRamps: {
occupancy: {
type: 'sequential',
source: 'anchor:blue',
steps: 5,
},
variance: {
type: 'diverging',
start: 'anchor:blue',
end: 'anchor:red',
neutral: 'anchor:paper',
steps: 7,
},
},
};
Those declarations emit --esp-color-ramp-occupancy-1 through
--esp-color-ramp-occupancy-5 and --esp-color-ramp-variance-1 through
--esp-color-ramp-variance-7. Ramp names are lowercase slugs. No ramps are
emitted by default.
Data colors are absolute — the engine never re-lights them
Interface tokens re-derive per scheme and per context; series colors do not, on purpose. A categorical identity that shifted between light and dark mode would stop being an identity — series 3 in a screenshot must be series 3 in the dark-mode dashboard next to it. The cost is that a saturated series color can sit harshly on a dark surface.
One consequence to handle deliberately: the light and dark theme partials
each declare their own dataPalette, and emission reads the active
scheme's. An override placed only in the light partial leaves the dark
scheme on the default palette — a changed identity, not a preserved one.
When identity must hold across schemes (it usually must), share one palette
object of concrete colors between both partials:
const dataPalette = { series1: '#78486A', series2: '#B76E79' };
const lightTheme = { /* … */ dataPalette };
const darkTheme = { /* … */ dataPalette };
A shared object is not enough on its own if it references anchors: an
anchor: value resolves against each scheme's own anchor table, so
series1: 'anchor:plum' still diverges when the light and dark partials
declare different plums. Use concrete colors in a shared palette, or keep
every anchor it references byte-identical in both partials.
Choose the companion by what carries the series identity: fill for a large chart area, tint for a ground behind ordinary copy, and ink when the series color itself carries words.
When a chart fill needs to sit more comfortably on the local surface, use the shipped wash companion instead of changing the identity:
.revenue-area {
/* 75% of the series identity, washed toward the local background in
OKLab — the hue stays recognizable, the weight follows the scheme. */
fill: var(--esp-color-series-1-wash);
}
For a category-tinted row, chip, cell, or tag, use the subtler tint. It mixes 12% of the series identity over the local background, so ordinary text tokens remain legible instead of turning the whole surface into a saturated data mark:
.schedule-row[data-class='workshop'] {
background: var(--esp-color-series-2-tint);
color: var(--esp-color-text);
}
The engine composes every companion against --esp-color-background: the
root's token, or the zone's token inside a theme zone. It does not inspect the
surface the consuming rule happens to paint. A tint is a quiet category cue
behind copy, not a guarantee of a separately visible surface. Even on
--esp-color-background, four of eight default-light tints sit below the
system's ΔE-OK 0.045 surface-distinguishability floor: series 5 at 0.0235,
series 3 at 0.0333, series 2 at 0.0345, and series 8 at 0.0401. Six of eight
fitted-light tints do too. A uniform 12% share produces roughly fivefold
separation across shipped series whose source lightness spans 0–0.902.
Paint a tint directly on the background it was composed against. On
--esp-color-layer-1, seven of eight default-light tints and six of eight
default-dark tints fall below that floor (ranges 0.012–0.044 and 0.009–0.037
respectively). On an elevated surface, derive or override the tint against the
actual layer instead.
Every series ships three companions. The root emits its own set and every
theme zone re-emits them against the zone's --esp-color-background, which a
hand-written color-mix on a custom property cannot do (the mix would bake
against the surface that declared it):
--esp-color-series-N-wash— the fill recipe above, baked: 75% series identity, 25% local background, mixed in OKLab.--esp-color-series-N-tint— the ground-behind-copy recipe: 12% series identity, 88% local background, mixed in OKLab. Default--esp-color-textretains at least APCA Lc 60 across all eight shipped series in both schemes. Lc 60 is a legibility floor, not parity with the Lc 75 text tier enforced on a plain background: the shipped minima are Lc 70.93 in light and 77.43 in dark, so body copy can read slightly weaker than the same text on the canvas.--esp-color-series-N-ink— the series identity pushed until it clears the text tier's contrast target against the local background, for the moments a series color must carry words (legend text, a value label on the canvas). A mid-lightness background can make the tier unreachable from either side; the ink then takes whichever pole scores higher — the best contrast that surface admits. The identity tokens themselves receive no contrast enforcement — that is what the ink companion is for.
Keep full-strength series colors for strokes, points, and legend chips — the identity carriers. Use wash only for large fills, tint for surfaces behind copy, and ink when the colored series identity is the copy.
What the engine does not promise
- Series colors are not text tokens and receive no APCA enforcement — use
--esp-color-series-N-inkwhen a series color must act as text on the local canvas. Text sitting on a data mark (a label inside a bar) is still a pair you measure yourself. - A CVD warning is a useful signal, not a medical diagnosis or accessibility certification.
- A sequential ramp encodes order through lightness. Do not shuffle its stops.
- Never borrow
danger,warning, orsuccessto fill out a chart palette; those hues carry interface meaning even when the chart legend says otherwise.