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
colorplus 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, likeseedColor. - A mapping references the base as
anchor:<name>and a slot asanchor:<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
intentandcontext— brand colors do not rotate with an element's status or zone. validateThemetreats 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 series1–series8 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.