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-text retains 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-ink when 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, or success to fill out a chart palette; those hues carry interface meaning even when the chart legend says otherwise.
Components API Guides Getting started Styling Espalier Browser support GitHub npm package Taproot I/O