Build a Site

Build a themed site from a set of brand colors. This guide configures anchors, roles, lightness ramps, and contexts, then composes a page using the shipped header, section, and footer components.

The example uses a plum and cream brand with a rose accent, also used in the theming and lightness-ramp guides. Replace those swatches with your own. Read getting started first if you have not installed Espalier; page anatomy explains the components used here.

Step 1 — Declare your anchors

Open the brand book, list the swatches, and declare each one as a named anchor. Sub-slots hold the per-family variants designers already specify:

const anchors = {
  plum: "#78486A",
  espresso: "#4A3327",
  cream: "#FFF1D9",
  rose: { color: "#B76E79", text: "#9B4D59", light: "#E3C7CB" },
};

Hand colors over exactly as they arrive in one of the supported opaque forms — #rrggbb, #rgb, rgb(), hsl(), or oklch(); CSS keywords such as red and other color spaces (lab(), color()) are not parsed — and each converts to OKLCH at the boundary. Nothing renders yet; anchors are a vocabulary.

Step 2 — Write the role lines

Roles assign colors to UI purposes: canvas for page backgrounds, ink for text, accent for links and decoration, action for filled buttons, and structure for borders and rules:

const roles = {
  canvas: "anchor:cream",
  ink: { color: "anchor:espresso", heading: "anchor:plum" },
  accent: { color: "anchor:rose", text: "anchor:rose.text" },
  action: { color: "anchor:plum", ink: "anchor:cream" },
  structure: "anchor:rose.light",
};

The engine compiles roles into semantic color mappings and adjusts text colors toward their APCA targets. The fit report identifies any pairs that cannot reach those targets. Use raw semanticMappings only for the tokens roles do not cover — see Semantic Color Groups for which are safe to touch.

Step 3 — Seat the lightness ramp

Sort the swatches by OKLCH lightness and set suitable ramp stops to their measured values. Fill the remaining stops between them. The lightness-ramp guide works through the numbers for this brand:

const lightness = {
  surface: 0.963, // cream, exactly
  raised1: 0.988, // paper white above the cream canvas
  raised2: 0.926,
  raised3: 0.854, // rose.light, exactly
  raised4: 0.78,
  border: 0.854,  // the same soft swatch does borders
  accent: 0.52,   // rose.text, exactly
  muted: 0.469,   // plum, exactly
  text: 0.344,    // espresso, exactly
  ink: 0.362,
  shadow: 0.25,
};

Step 4 — Name your zones

Every region of the page that changes color identity — the reversed band, a quiet tint — is a named context: the same role lines, rebound for a zone, usually with a partial ramp of its own. A reversed plum band:

const contexts = {
  inverted: {
    canvas: "anchor:plum",
    ink: { color: "anchor:cream", heading: "anchor:cream" },
    accent: { color: "anchor:rose.light", text: "anchor:rose.light" },
    action: "anchor:rose",
    structure: "anchor:plum",
    lightness: {
      surface: 0.362, raised1: 0.41, raised2: 0.46, raised3: 0.51,
      raised4: 0.56, border: 0.5, accent: 0.854, muted: 0.95,
      text: 0.9, ink: 0.98, shadow: 0.15,
    },
  },
};

Note the zone's ground: surface: 0.362, well below the plum swatch's own 0.469. A reversed band whose canvas sits in the mid band swallows its own filled buttons — the rule and the reasoning are in the theming guide.

Step 5 — Assemble both schemes and validate the pair

A theme is the four pieces from steps 1–4 plus the seed; a site ships a light and a dark one. Dark reuses the anchors and roles and reseats the ramp (deep surfaces, light text — see the ramp guide); context names must match across schemes, bindings may differ:

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

const light = { seedColor: "#78486A", anchors, roles, lightness, contexts };
const dark = {
  seedColor: "#78486A",
  anchors,
  roles,
  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,
  },
  contexts,
};

const result = validateThemePair(encodeTheme(light), encodeTheme(dark));
if (!result.valid) console.error(result.errors);
result.warnings.forEach((warning) => console.warn(warning));

Run this before mounting, and read what it says: errors name undeclared anchors and grammar mistakes; warnings are the design lints — status collisions as rendered, bands that clamp declared swatches — each with the remedy that actually works.

This exact theme demonstrates why the step exists. As written so far, the pair validates with one warning per scheme:

light: contexts.inverted: Status colors "danger" and "action" are hard to
distinguish under normal vision (distance 0.0280 < 0.045); status meanings
must stay distinguishable — retune "danger" via intents.

The rose action in the inverted band renders about seventeen hue degrees from the default danger family at nearly the same lightness and chroma — a delete button on the plum band would not read as one. The warning names the fix: retune danger with an intents override, a deep saturated red that stays danger-shaped everywhere while clearing the rose decisively. Add it to both schemes:

const intents = { danger: "oklch(0.55 0.2 27)" };
const fixedLight = { ...light, intents };
const fixedDark = { ...dark, intents };

Re-run the validation with fixedLight / fixedDark: no errors, no warnings.

Mount the pair. From script, assign the theme objects directly — the lightTheme / darkTheme properties encode into the same pipeline the attributes feed:

const root = document.querySelector("esp-root");
root.lightTheme = fixedLight;
root.darkTheme = fixedDark;

A script-mounted theme lands after the elements upgrade, so add theme-pending to the root and the page holds its paint until the brand theme applies — no frame on the default palette, no layout shift when it reveals:

<esp-root theme-pending>
  <!-- your page -->
</esp-root>

The hold always ends: if no theme arrives within a second the root reveals on the defaults and says so in the console, so a broken mount degrades to the wrong colors rather than a blank page.

The attribute covers everything from the moment the element upgrades. To close the window before your scripts run — the frame where the browser has parsed your markup but no Espalier code has executed — add one line to the page itself, which is the only thing that can style content painted that early:

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

The root sets data-theme-ready when its theme settles, so the rule releases on its own. Keep the [theme-pending] part: it scopes the hold to roots that asked for one, and an unscoped rule would also blank a page that mounts no theme at all until its window times out.

Server-rendered HTML carries the encoded form instead: the light-theme / dark-theme attributes take encodeTheme(fixedLight)'s Base64 string. That closes the script-assignment window — the theme is already there when the root upgrades, so it settles on its first update and never renders the default palette.

It does not close the window before the module runs. Until Espalier's script executes, <esp-root> is an unknown element and its content paints with your page's styles alone. If the module can load after the browser's first paint — a deferred bundle, a slow network — a server-rendered page wants the same theme-pending attribute and the same static gate; the root settles immediately on upgrade, so the hold releases at once:

<esp-root theme-pending light-theme="eyJzZWVkQ29sb3IiOiIjNzg0ODZBIn0=">
  <!-- your page -->
</esp-root>

Import the shell components:

import "@taprootio/espalier/root";
import "@taprootio/espalier/page";
import "@taprootio/espalier/section"; // full-bleed bands with centered wells
import "@taprootio/espalier/stack";   // column flow with themed gaps
import "@taprootio/espalier/row";     // wrapping rows (button pairs, badges)
import "@taprootio/espalier/header";  // registers esp-header + esp-header-button
import "@taprootio/espalier/menu";    // horizontal nav for the header
import "@taprootio/espalier/menu/item";
import "@taprootio/espalier/footer";  // registers esp-footer
import "@taprootio/espalier/footer/link-group";
import "@taprootio/espalier/button";

Keep generated-site entries on component and shared-module subpaths. The package root intentionally registers the complete component catalog, so using it only to obtain a helper also makes that catalog reachable from the initial bundle. Runtime infrastructure has leaf imports of its own:

import { ESP_EVENTS } from "@taprootio/espalier/shared/events";
import { getEspBus } from "@taprootio/espalier/shared/bus-events";
import { showToast } from "@taprootio/espalier/shared/toast-events";
import { EspalierElementBase } from "@taprootio/espalier/shared/element-base";

Import only the helpers and component registrations that the generated page uses. A non-resizable esp-page then leaves its pointer/keyboard workspace session in a deferred capability chunk; setting workspace-resizable loads that chunk before the resize handles become available.

The rule extends past the browser bundle to the generator itself. Some shared subpaths register no custom element and touch no DOM at all, so the Node process that emits the page can import them directly — no DOM shim, no component catalog, no risk of registering an element the page will register again:

import { BUILT_IN_IMAGE_TEXTURES } from "@taprootio/espalier/shared/image-texture-registry";
import { computeThemeProperties } from "@taprootio/espalier/shared/theme-properties";

The first is the same registry esp-image reads, so a build step can check a texture="…" name against the built-in vocabulary esp-image implements, or offer it in an editor, alongside whatever the application registers with registerImageTexture (an unknown name renders as none). The second is the same computation esp-root runs when it hydrates, so the shell's first-paint background is the color the upgraded page will keep rather than an approximation of it — see Theming.

Use esp-page kind="site" for edge-to-edge sections with a shared content column (--esp-page-well-max-width). Each esp-section centers its content; context="inverted" selects the reversed band. esp-stack and esp-row arrange content within a section. esp-header supplies branding, navigation, and the scheme toggle (theme-toggle="visible"); esp-footer arranges labelled link groups.

Name recurring text voices with the typography role tokens rather than borrowing a link color. This overline recipe keeps its native inline element and receives the active context's Lc 90 ink:

.overline {
  font-size: var(--esp-type-overline-font-size);
  font-weight: var(--esp-type-overline-font-weight);
  letter-spacing: var(--esp-type-overline-letter-spacing);
  text-transform: var(--esp-type-overline-text-transform);
  color: var(--esp-type-overline-color);
}

.display {
  font-family: var(--esp-type-display-font-family);
  font-size: var(--esp-type-display-font-size);
  font-weight: var(--esp-type-display-font-weight);
  line-height: var(--esp-type-display-line-height);
  letter-spacing: var(--esp-type-display-letter-spacing);
  color: var(--esp-type-display-color);
}

.hero {
  --esp-section-decoration-image: url('/assets/brand-mark.png');
  --esp-section-decoration-position: calc(100% + 2rem) -2rem;
  --esp-section-decoration-size: min(34rem, 62%);
  --esp-section-decoration-opacity: 0.09;
}
<esp-root default-scheme="system">
  <esp-page kind="site">

    <esp-header slot="header" brand-text="Meridian" brand-href="/"
        theme-toggle="visible">
      <esp-menu slot="menu" mode="horizontal">
        <esp-menu-item label="Classes" url="#classes"></esp-menu-item>
        <esp-menu-item label="Visit" url="#visit"></esp-menu-item>
      </esp-menu>
    </esp-header>

    <esp-section class="hero" decoration>
      <esp-stack gap="medium">
        <span class="overline">Studio &amp; sauna</span>
        <h1 class="display">Rooted in warmth</h1>
        <p>Body copy renders espresso on cream — the roles you wrote in
        step 2, on the ramp you seated in step 3.</p>
        <esp-row gap="small">
          <esp-button label="Book a class"></esp-button>
          <esp-button label="See schedule"></esp-button>
        </esp-row>
      </esp-stack>
    </esp-section>

    <esp-section context="inverted">
      <esp-stack gap="normal">
        <h2>The reversed band</h2>
        <p>Cream ink on the plum ground, edge to edge; the action below
        is the zone's rose, sized against the deeper surface from
        step 4.</p>
        <esp-row>
          <esp-button label="See the sauna"></esp-button>
        </esp-row>
      </esp-stack>
    </esp-section>

    <esp-footer slot="footer" columns="3" brand-text="Meridian">
      <esp-footer-link-group heading="Explore">
        <a href="#classes">Classes</a>
        <a href="#visit">Visit</a>
      </esp-footer-link-group>
      <esp-footer-link-group heading="Studio">
        <a href="#about">About</a>
        <a href="#contact">Contact</a>
      </esp-footer-link-group>
      <esp-footer-link-group heading="Legal">
        <a href="#privacy">Privacy</a>
      </esp-footer-link-group>
      <span slot="bottom">© Meridian. All rights reserved.</span>
    </esp-footer>

  </esp-page>
</esp-root>

The hero decoration uses the raster's alpha as a mask and tints it with the section's local --esp-color-headings, so the same asset follows light, dark, and named contexts. The decoration attribute turns the plane on; a section without it renders no decorative layer, so accessibility audits measure its text against the band itself. The section owns clipping, stacking, no-repeat behavior, and pointer isolation. The image hook is trusted stylesheet input: if a CMS or user can choose assets, validate ownership and allowed URLs before mapping their value into --esp-section-decoration-image.

(Cards keep esp-box; a grid of them is two lines of content CSS with the --esp-card-min token — grid-template-columns: repeat(auto-fit, minmax(var(--esp-card-min), 1fr)).)

Persist the visitor's explicit toggle choice with one listener (the toggle emits only for real activations, never for automatic scheme resolution):

root.addEventListener("esp-header-theme-toggle", (event) => {
  localStorage.setItem("scheme", event.detail.scheme);
});
const saved = localStorage.getItem("scheme");
if (saved === "light" || saved === "dark") root.scheme = saved;

What each region is, which component owns it, and what else the shell can do (sidebars, rails, scroll behaviors, the mobile drawer) is the Page Anatomy reference.

Step 6.5 — Icons

Components that own an icon position take a symbol id, resolved against the sprite the root names:

<esp-root icon-sprite-url="/assets/icons.svg">
  <esp-button label="Book a class" icon="calendar"></esp-button>
</esp-root>

/assets/icons.svg is the default, so a site serving the sprite there writes nothing. Point icon-sprite-url at your own sprite — a CDN path, a hashed asset, or a sprite of your own symbols — and those ids become your icon vocabulary. Working with SVG icons covers building one.

If the page must be self-contained — an email attachment, a single-file deliverable, anything that cannot fetch /assets/icons.svg — inline the sprite instead and reference it in the same document:

<esp-root icon-sprite-url="#esp-icons" theme-pending>
  <!-- your page -->
</esp-root>

<script type="module">
  import { installIconSprite } from "@taprootio/espalier/icons";
  installIconSprite();               // inline the sprite (idempotent)
  await import("@taprootio/espalier/root");
  document.querySelector("esp-root").lightTheme = fixedLight;
</script>

Two details make it a genuinely zero-request page: the fragment form (icon-sprite-url="#esp-icons") belongs in the markup, and the sprite must be installed before the component modules load — a root that renders even once on the default URL fetches it. Components then adopt just the symbols they use into their own shadow roots.

Step 7 — Run the fit check

The theme fit report compares requested and emitted colors for each semantic token and lists APCA adjustments. Run it from the command line over the theme file — both schemes, the root, and every zone you declared in step 4:

npx espalier theme check ./theme.json

theme.json is { "light": { … }, "dark": { … } } — the two partials from step 5. The command validates the pair exactly as the runtime does, then prints a line per surface plus any cross-token lints (a filled action that would vanish into its own canvas; a hover that reads weaker than the resting link on the surface it actually paints). Every suite lint is a CI gate: the command prints the finding and exits 1 even when the committed fit tables match.

Commit the resolved tables next to the theme and gate on them:

npx espalier theme check ./theme.json --update-expect ./fit-tables.json  # once
npx espalier theme check ./theme.json --expect ./fit-tables.json         # in CI

--expect exits 1 when the resolved values drift from the committed ones and names every moved token — so a theme change reviews as a diff of rendered colors alongside visual review. Fit tables written by Espalier 4.2.0 or earlier predate the scheme palette fields and exit 2 with one-time regeneration guidance. Refreshing the tables is a deliberate act, like updating a visual baseline.

The same reports are available programmatically — themeFitReportSuite(lightPartial, darkPartial) returns the whole set, and themeFitReport(theme, scheme, { context }) a single surface — when you want them inside an app or a custom check.

The path, condensed

Step You write Deep documentation
1 anchors — the swatches, verbatim Theming § anchors
2 roles — five lines of assignment Semantic Groups
3 lightness — swatches seated on stops The Lightness Ramp
4 contexts — one entry per zone Theming § contexts
5 two theme objects + validateThemePair Theming
6 composition: page, header, band, footer Page Anatomy
6.5 icon ids, and the inline sprite when offline SVG icons
7 espalier theme check, tables committed Fit Report

Everything past this point — forms, pickers, dialogs, data display — is the component catalog, which now renders in your brand everywhere, zones included, because color was configured once at the root instead of per component.

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