Theming Espalier

A theme is a configuration object passed to <esp-root> via its light-theme and dark-theme properties. The system generates the complete set of --esp-color-*, --esp-type-*, and --esp-size-* tokens from this configuration at render time.

The minimal theme

The only required field is seedColor. Everything else has a default:

import { EspalierRoot } from '@taprootio/espalier';

const myTheme = {
  seedColor: 'oklch(0.65 0.18 240)', // your brand color
};

seedColor accepts any common CSS color form — #rrggbb, #rgb, rgb(), hsl(), or oklch() — so a brand color can be handed over exactly as it arrives:

const myTheme = {
  seedColor: '#78486A', // converted to oklch(0.469 0.0809 338.5) internally
};

Non-OKLCH forms are destructured into OKLCH at the theme boundary, so the rest of the pipeline always works in perceptual coordinates. The converted value is printed at converter display precision (lightness to 3 decimals, chroma to 4, hue to 1) whenever that rounding reproduces the identical 8-bit color; fully saturated colors near the sRGB gamut surface keep full conversion precision instead, so nothing is ever desaturated by rounding. Alpha channels are rejected: theme colors are opaque. Writing oklch() directly remains the precision-preserving native form.

Pass it to <esp-root>. From script, assign the object to the lightTheme / darkTheme properties — each encodes into the same pipeline the attributes feed:

const root = document.querySelector('esp-root');
root.lightTheme = myTheme;
// The same partial can serve both schemes: each merges over its own
// scheme's defaults. Pass a separate dark partial when the schemes
// should diverge (a reseated ramp, different anchors).
root.darkTheme = myTheme;

A theme assigned from script lands after the elements upgrade. Add theme-pending to the root and it holds its paint until the theme for the active scheme applies, so the page never shows a frame of the default palette; the root reveals itself (and warns) if no theme arrives.

That covers everything from upgrade onward. The frame before your scripts run is the page's to close — the root mirrors its state to data-theme-ready, so one line in your markup does it:

<style>
  esp-root[theme-pending]:not([data-theme-ready]) { visibility: hidden; }
</style>

The light-theme / dark-theme attributes (and their lightThemeAttr / darkThemeAttr string properties) carry encodeTheme's Base64 form instead — the transport for server-rendered HTML:

<!-- or server-rendered: present when the root upgrades, so it settles
     on the first update and never renders the defaults. Keep
     theme-pending (with the gate above) when the bundle may load after
     the browser's first paint — until it does, this is an unknown
     element painting its light DOM unstyled. -->
<esp-root theme-pending light-theme="eyJzZWVkQ29sb3IiOiJva2xjaCgwLjY1IDAuMTggMjQwKSJ9">
  <!-- your app -->
</esp-root>

Brand anchors

A single-color brand never needs anchors — the seed's derived families cover everything. A multi-color brand declares its swatches as anchors and points semantic mappings at them by name, instead of bending hue angles until brand colors fall out of the geometry:

const myTheme = {
  seedColor: '#78486A', // plum
  anchors: {
    espresso: '#4A3327',
    cream: { color: '#FFF1D9', paper: '#FDFBF4' },
    rose: { color: '#B76E79', hover: '#9B4D59' },
  },
  semanticMappings: {
    text: { source: 'anchor:espresso', lightness: 'text' },
    background: { source: 'anchor:cream', lightness: 'surface' },
    layer1: { source: 'anchor:cream.paper', lightness: 'raised1' },
    linkHover: { source: 'anchor:rose.hover', lightness: 'accent' },
  },
};
  • An anchor is a color string, or an object with a base color plus named sub-slots — per-family variants the way designers already hand them over. Every value accepts any CSS color form and is converted to OKLCH internally, like seedColor.
  • A mapping references the base as anchor:<name> and a slot as anchor:<name>.<slot>. The anchor contributes its own hue and chroma; lightness still comes from the mapping's built-in ramp stop or named custom tone, and APCA contrast enforcement applies unchanged — declare the swatch, and the system keeps it legible.
  • Anchors and the geometric families compose freely in one mapping table, and anchor-sourced tokens stay absolute under intent and context — brand colors do not rotate with an element's status or zone.
  • validateTheme treats a reference to an undeclared anchor or slot as a hard error that names what is declared. Anchor names are lowercase slugs and cannot shadow the built-in source names.

Because lightness comes from the mapping's built-in stop or custom tone, and contrast enforcement may move it further, an anchor-sourced token is a relative of its swatch rather than the swatch itself. A theme fit report shows how far each one landed from the color you declared.

Theme-defined contexts

Named contexts turn a section of the page into a theme-defined color zone. A context rebinds the same designer-facing roles as the root and may override part of that scheme's lightness ramp. It may also pin individual tokens with its own semanticMappings, including to an isolated custom tone. Espalier then derives and APCA-enforces all twenty-three semantic colors and the context-local type-role color aliases on the context host:

import { encodeTheme, validateThemePair } from '@taprootio/espalier';

const lightTheme = {
  anchors: {
    plum: '#78486A',
    espresso: '#4A3327',
    cream: '#FFF1D9',
    rose: '#B76E79',
  },
  roles: {
    canvas: 'anchor:cream',
    ink: { color: 'anchor:espresso', heading: 'anchor:plum' },
    action: 'anchor:plum',
  },
  contexts: {
    inverted: {
      canvas: 'anchor:plum',
      ink: { color: 'anchor:cream', heading: 'anchor:cream' },
      action: 'anchor:rose',
      lightness: {
        surface: 0.32,
        raised1: 0.36,
        raised2: 0.40,
        raised3: 0.44,
        raised4: 0.48,
        text: 0.90,
        ink: 0.95,
      },
      tones: {
        recessed: 0.24,
      },
      semanticMappings: {
        linkHoverBg: { source: 'anchor:plum', lightness: 'tone:recessed' },
      },
    },
    quiet: {
      canvas: 'anchor:cream',
      ink: 'anchor:espresso',
    },
  },
};

// Bindings and ramp values may differ, but the names must match.
const darkTheme = {
  ...lightTheme,
  contexts: {
    inverted: {
      canvas: 'anchor:cream',
      ink: 'anchor:espresso',
      lightness: { surface: 0.92, text: 0.30, ink: 0.20 },
    },
    quiet: {
      canvas: 'anchor:plum',
      ink: 'anchor:cream',
    },
  },
};

const result = validateThemePair(encodeTheme(lightTheme), encodeTheme(darkTheme));

Color sources contribute hue and chroma; roles and shared semantic lightness still come from the ramp. That is why a genuinely dark zone inside a light scheme declares a context-local partial ramp instead of expecting a dark anchor to override the root's near-white surface stop.

A context's lightness map is partially reseatable: it accepts only the eleven built-in stop names, requires every declared value to be a finite number from 0 to 1, and does not require the stops to follow the default ordering. Unknown stop names are validation errors, so a misspelling cannot validate and then disappear during compilation.

Use the shared ramp when a zone should move a coherent family of surfaces. Use tones for a one-off explicit ground. In the example, tone:recessed gives only linkHoverBg L 0.24 below the zone's L 0.32 surface; it does not move raised1, layer1, or any component that defaults to --esp-color-layer-1 inside the zone (menus, action menus, disclosure panels, and slider or switch thumbs). A custom tone does not emit a --esp-l-* token or participate in automatic role and action-stop selection. It is inert until a semanticMappings entry names it.

Keep a zone's canvas out of the action's band. When roles derive a filled action, its surface is placed on the ramp stop nearest a mid-band lightness target (L 0.45, ties toward darker) so labels can carry contrast — which means a zone whose own canvas sits near L 0.45 puts its filled buttons on the canvas: measured on a real brand, a plum band at L 0.469 swallowed its rose action entirely. The fix is the ramp, not the colors: give a reversed band a deeper ground (the example above seats the zone's surface at 0.32) so the mid band stays free for the action. See The Lightness Ramp for the stop-seating procedure.

Apply the name to any component derived from EspalierElementBase—containers such as esp-box are the usual host:

<esp-box context="inverted">
  <footer>Plain light-DOM content inherits the inverted tokens too.</footer>

  <esp-box context="quiet">
    The nearest context wins and re-enforces ink against this inner canvas.
  </esp-box>
</esp-box>

Context names are lowercase slugs. inverted and quiet are conventions, not reserved values; a product may define names that fit its own zones. An unknown name warns once per element and otherwise inherits the surrounding cascade. Use validateThemePair() before mounting a paired light/dark theme so the same markup cannot silently lose its context when the scheme changes.

Key theme fields

Field Type Default Effect
seedColor CSS color string required The brand hue that drives the entire palette
typeRatio number 1.25 The geometric ratio between type scale steps
spaceRatio number 1.5 The geometric ratio between spacing scale steps
fontBody string system-ui, sans-serif Body and UI font family; empty and omitted values resolve to the default stack
fontHeadings string empty → body Heading font family; the token is emitted only when configured
fontBrand string empty → headings → body Brand mark and product-name font family; the token is emitted only when configured
fontMenu string empty → body Navigation menu font family; the token is emitted only when configured and never falls back through headings
fontMonospace string monospace Code font family
fontWeightBrand string | number bold Brand mark and product-name font weight
fontWeightMenu string | number bold Horizontal navigation menu item weight
anchors object {} Declares named brand colors and optional sub-slots
lightness object scheme ramp Seats the eleven-stop lightness ramp — see The Lightness Ramp
tones object {} Declares isolated lightness values for explicit mappings as tone:<name>
roles object {} Assigns designer-facing canvas, ink, accent, action, and structure roles
contexts object {} Declares named, scoped role, lightness, tone, and semantic-mapping overrides
intents object {} Retunes the status families with absolute colors (anchor references or CSS colors); overrides replace semanticHues/variantChroma for that family
semanticMappings object see below Maps semantic roles to color families
explicitMappingTokens string[] merge metadata Preserves which mappings are authored pins when a complete resolved theme is stored
dataPalette object eight-color Okabe–Ito palette Overrides series1series8 categorical colors by slot
dataRamps object {} Declares named sequential and diverging ramps for CSS emission

Resolved themes always emit --esp-font-body before their content renders. Non-empty font-family fields retain the consumer's authored string exactly; runtime font-loading layers may construct an effective stack separately, but they do not rewrite the serialized theme value.

For a static site, use compileFontPlan() with both resolved schemes and the build-time font-fallback-profiles.json catalog. The compiler selects only the used family/weight variants, emits metric-matched local fallback faces, and returns internal effective stacks without changing the four authored theme fields above. Embed its compact CSS in the page head before first paint and use google-font-loading="none"; the host may point the selected target faces at Google-hosted WOFF2 files or its own origin/CDN. See the Font Picker guide's static production plan.

Data colors form a separate visualization vocabulary; see Data Colors and Ramps for palette validation, anchor-backed ramp declarations, and the public generator functions.

Color families

Espalier derives eight color families from your seed color using color-theory hue relationships. Each family is available for semantic mapping:

Family Hue relationship
primary Your seed color
analogous-left Adjacent (−offset)
analogous-right Adjacent (+offset)
complementary Opposite (+180°)
split-complementary-left Soft opposite (−offset from complement)
split-complementary-right Soft opposite (+offset from complement)
triadic-left Equidistant (+120°)
triadic-right Equidistant (+240°)

Four additional status families use fixed hues that do not change with your seed: danger (red-orange, 27), success (green, 150), warning (yellow-green, 90), and info (blue, 244). They carry the meanings behind the intent attribute and are emitted as --esp-color-danger, --esp-color-success, --esp-color-warning, and --esp-color-info.

Retuning status families with intents

A brand legitimately owns the color of a status while the system owns its meaning (ADR-004). The intents field replaces a family's derivation outright with a declared absolute color — an anchor reference or a supported color form (#rrggbb, #rgb, rgb(), hsl(), oklch() — CSS keywords such as red are not parsed):

{
  "anchors": { "sky": "#0072B2" },
  "intents": { "info": "anchor:sky", "danger": "#700007" }
}

An override carries its own lightness, chroma, and hue; semanticHues and variantChroma (the legacy hue/chroma knobs) are ignored for that family. Overrides retune, never reassign: give danger your brand's red, but never point one family at another — intents.success: "danger" is rejected outright. (The grammar accepts any supported color form; the rendered-collision warning is what flags a color choice that lands two statuses on each other.) Everything downstream follows one override: the emitted family token, intent= pins on controls, the class-based chrome on badges and callouts, and derived tokens such as dangerText.

Semantic mappings

semanticMappings controls which color family each semantic role uses. The defaults work well for most products:

// Example: move links to use the triadic-right family
const myTheme = {
  seedColor: 'oklch(0.65 0.18 240)',
  tones: { recessed: 0.24 },
  semanticMappings: {
    link: { source: 'triadic-right', lightness: 'accent' },
    linkHover: { source: 'triadic-right', lightness: 'text' },
    linkHoverBg: { source: 'primary', lightness: 'tone:recessed' },
  },
};

lightness normally names one of the eleven built-in ramp stops. An explicit mapping may instead name a declared custom tone as tone:<name>. Tones are for deliberate exceptions: they keep the one mapping inside the usual chroma, gamut, APCA, and fit-report pipeline without adding a shared ramp stop or a CSS lightness token.

For guidance on which mappings are safe to change and which should stay internal, see the Semantic Color Groups guide.

An explicit mapping is absolute — it follows the element into every zone. A token pinned in semanticMappings at the root stays pinned inside every context, resolving against whatever that zone's built-in stop or custom tone holds; roles recompile per zone, explicit mappings do not. That is exactly what you want for a genuinely fixed assignment ("links are always the brand plum") and exactly wrong for anything that should re-derive against a zone's canvas — express those through roles and contexts instead, and reserve semanticMappings for tokens the roles do not cover.

Two tools keep that inheritance traceable rather than silent. A context declaration may carry its own semanticMappings, which layer over the inherited pin for that zone alone:

contexts: {
  inverted: {
    canvas: 'anchor:plum',
    // The root pins link to the brand plum — invisible on the plum band.
    // The zone corrects the one token and inherits everything else.
    semanticMappings: { link: { source: 'anchor:cream', lightness: 'ink' } },
  },
},

And the fit report marks every pinned token as declared or inherited per surface, so a zone's surprising value traces to the root line that caused it.

Storing a complete resolved theme

mergeTheme() returns all twenty-three resolved semanticMappings, including the entries compiled from roles. It also returns explicitMappingTokens, a compact provenance list maintained by the merge layer. Keep both fields when serializing a complete theme document. On rehydration, the stored root mappings remain complete, while generated role mappings can recompile inside a context and authored pins remain absolute. Hand-authored partial themes do not need the marker: without it, every supplied semanticMappings entry is still treated as an authored pin. Editing a stored document's semanticMappings by hand requires updating explicitMappingTokens in the same edit. A stored theme with no pins carries an empty marker, and a mapping added under it is treated as a generated value — role compilation will overwrite it on the next merge without reporting anything.

Light and dark themes

Pass separate theme objects for light and dark mode. The system switches between them based on <esp-root>'s active scheme attribute. When scheme is not set, default-scheme="light|dark|system" chooses the initial active scheme; system uses the user's prefers-color-scheme preference:

--esp-scheme is emitted by Espalier as an output token for the active scheme. It no longer seeds the initial scheme from CSS; set default-scheme="light|dark|system" when a site needs to choose its starting mode before visitors toggle it.

const lightTheme = { seedColor: 'oklch(0.65 0.18 240)' };
const darkTheme  = { seedColor: 'oklch(0.65 0.18 240)', isDark: true };

Dark themes automatically adjust lightness ramp positions to keep surfaces dark and text light. The same seed color works for both schemes.

Resolving a theme outside the browser

A generator or server that renders a page's shell often needs a resolved token before any element upgrades — the body background that must be painted at first paint, or a swatch in a build-time preview. esp-root computes those from the theme when it hydrates; the same computation is published so nothing has to guess or re-derive it:

import { computeThemeProperties } from '@taprootio/espalier/shared/theme-properties';
import { DEFAULT_LIGHT_THEME, mergeTheme } from '@taprootio/espalier/shared/theme';

const theme = mergeTheme(DEFAULT_LIGHT_THEME, { seedColor: 'oklch(0.65 0.18 240)' });
const properties = computeThemeProperties(theme, 'light');

properties['--esp-color-background']; // the background the page will paint

Both subpaths register no custom element and touch no DOM, so they run in Node with no shim. Pass a resolved theme — merge your partial over DEFAULT_LIGHT_THEME or DEFAULT_DARK_THEME first — and pass the scheme that theme was written for. The return value is the flat --esp-* record esp-root writes onto its host, in the same order, so serializing it into a <style> block reproduces first paint exactly.

Run validateTheme(encodeTheme(partial)) on authored input before rendering (validateThemePair for a light/dark pair): an unparseable seedColor yields an empty record and an unresolvable color source falls back to the seed with a warning, which keeps a bad theme rendering branded rather than broken but is not something a build step should ship silently.

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