The Lightness Ramp

Anchors and roles declare which colors a theme uses. The lightness ramp decides where those colors sit — every semantic token takes its hue and chroma from a color source, then normally takes its lightness from a named ramp stop. Fitting a brand is therefore two separate acts: declare the swatches (anchors), and seat them on the ramp. This page documents the second act, which is where the judgement lives. A one-off explicit mapping may use an isolated custom tone instead.

The eleven stops

Each scheme carries one ramp: eleven named stops, each a lightness value from 0 to 1. The defaults:

Stop Light default Dark default Feeds (default mappings)
surface 0.99 0.15 background — the page canvas
raised1 0.97 0.20 layer1 — cards, boxes
raised2 0.90 0.30 layer2, linkHoverBg, inputSelectionBg
raised3 0.80 0.40 layer3, actionBackground — filled buttons
raised4 0.72 0.36 layer4
accent 0.50 0.60 link
muted 0.45 0.75 headings
text 0.35 0.85 text, linkHover, headingsHover, dangerText
border 0.30 0.70 border
ink 0.25 0.95 actionText, inputCaret — the most extreme step
shadow 0.20 0.60 shadow

Two structural facts to keep in mind:

  • The ramp inverts between schemes. surface is near-white in light and near-black in dark; text and ink do the opposite. The stop names describe function, not brightness.
  • Stops are a shared vocabulary, not a private detail. Every default mapping, every role compilation, and every context resolves its built-in lightness through these names — move a stop and everything seated on it moves together. That coupling is the point: it is what keeps a theme coherent when you re-tune one number. Explicit mappings can opt out of that coupling with a custom tone when one value truly is local.

The procedure: seat the brand book on the ramp

A brand book arrives as a handful of swatches, not a ramp. The mechanical steps below are automated — themeFromSwatches({ swatches }) returns a paired partial-theme starter (anchors, seated ramps for both schemes, and the role guesses lightness alone justifies), and deriveLightnessRamp exposes the seating step on its own. The judgement calls the walk-through points out remain yours: the starter is the input to this procedure's thinking, not a substitute for it. The fitting procedure that produced the worked example below:

1. Convert every swatch to OKLCH and sort by lightness

Use any converter, or let Espalier do it — seedColor and anchors accept #rrggbb and convert internally (see OKLCH Notation). What matters is the L channel:

cream    #FFF1D9  →  L 0.963
rose-200 #E3C7CB  →  L 0.854
rose-500 #B76E79  →  L 0.621
rose-700 #9B4D59  →  L 0.520
plum     #78486A  →  L 0.469
espresso #4A3327  →  L 0.344

2. Seat each swatch on the stop it naturally occupies

Ask, for each swatch: what job does a color at this lightness do? A 0.96 swatch is a canvas. A 0.85 swatch is a raised surface or a soft border. A 0.47 swatch is mid-band — headings, links, filled actions. A 0.34 swatch is body text. Then set those stops to the swatch's exact measured lightness, so the anchor-sourced tokens seated there reproduce the declared color instead of a relative of it:

surface  0.963   ← cream, exactly
raised3  0.854   ← rose-200, exactly (also border: same swatch, same stop value)
accent   0.520   ← rose-700, exactly
muted    0.469   ← plum, exactly
text     0.344   ← espresso, exactly

This is the step that turns "an anchor is a relative of its swatch" into "an anchor lands on its swatch": the mapping takes hue and chroma from the anchor, lightness from the stop — when the stop is the swatch's lightness, the token reproduces the swatch.

3. Fill the gaps between seated stops

The remaining stops interpolate between their seated neighbors, preserving the ramp's ordering (surfaces stay lighter than text in a light scheme). From the same fit:

lightness: {
  surface: 0.963,  // cream
  raised1: 0.988,  // a paper white above the cream canvas
  raised2: 0.926,  // between canvas and rose-200
  raised3: 0.854,  // rose-200
  raised4: 0.78,   // stepping down toward the mid band
  border:  0.854,  // rose-200 again — soft borders on cream
  accent:  0.52,   // rose-700
  muted:   0.469,  // plum
  text:    0.344,  // espresso
  ink:     0.362,  // near espresso — filled-action labels
  shadow:  0.25,
}

Note two judgement calls the numbers show: raised1 was seated above surface (a paper white floating over a cream canvas — the default ramp assumes the opposite), and border reuses raised3's value because the brand's softest swatch does both jobs. The ramp is a description of the brand, not a formula.

4. Derive the dark ramp with the same relationships

Repeat the procedure against the dark scheme's inverted expectations: deep values for surfaces, high values for text, and the same brand swatches seated where they still work. A dark ramp is usually not 1 − light — compressed surface steps (0.21 → 0.42) with widely spaced text steps read better than a mirror image:

lightness: {
  surface: 0.21, raised1: 0.25, raised2: 0.3, raised3: 0.36, raised4: 0.42,
  border: 0.38, accent: 0.75, muted: 0.82, text: 0.9, ink: 0.95, shadow: 0.1,
}

5. Verify with the fit report

Run a theme fit report for both schemes. The report shows, token by token, the color the theme asked for and the color that rendered — a seated anchor should show a near-zero delta, and any APCA adjustment is listed by name. If a seated swatch moved, its stop is fighting the contrast floor; move the stop or accept the enforced value deliberately.

The stop the action lives on

One stop deserves special attention: the effective actionBackground stop (raised3 by default). Filled buttons render there, and the engine chooses an action surface targeting a mid-band lightness (an internal target near L 0.45) so labels can carry contrast in both directions. Two consequences:

  • Keep the action stop distinguishable from the canvas it sits on. A zone whose canvas lightness lands near the action's own band swallows its filled buttons — see contexts and the action surface for the zone-side rule.
  • Status colors relight to this stop too. The collision warning audits statuses as rendered at the effective action stop, so an extreme stop value (near 0 or 1) is itself a findable theming defect: everything relit there converges.

Contexts carry partial ramps

A context may override part of the ramp for its zone only — the usual reason is a genuinely dark band inside a light scheme, whose surface stop must drop from ~0.96 to ~0.36 while the root ramp stays put. Seat a context's ramp with exactly the same procedure, against the zone's own canvas.

Custom tones for one-off values

Do not re-seat a shared stop merely to create one recessed, pressed, or hovered ground. Declare a custom tone and reference it from the specific semantic mapping instead:

contexts: {
  inverted: {
    lightness: { surface: 0.32, raised1: 0.36, raised2: 0.40 },
    tones: { recessed: 0.24 },
    semanticMappings: {
      linkHoverBg: { source: 'anchor:plum', lightness: 'tone:recessed' },
    },
  },
},

tone:recessed still supplies only lightness; the mapping source supplies hue and chroma, and normal gamut and APCA processing still runs. The difference is scope: the tone changes only mappings that name it. It never joins the eleven stops, emits --esp-l-recessed, or becomes a candidate for roles and automatic action placement. Tone names are lowercase slugs that may not reuse one of the eleven built-in stop names, their values are finite numbers from 0 to 1, and a reference to an undeclared tone is a validation error.

Components API Guides Getting started Styling Espalier Browser support GitHub npm package Taproot I/O