Build a Site

You have a brand book on the desk and a page to build. This guide is the path between those two things — seven steps, each linking to its deep documentation. At the end you have a themed page with a header, a reversed band, and a footer, and you will not have written any of those three yourself: they ship. (If you are ever unsure whether a region of a page has a component, check Page Anatomy before building it.)

The worked brand throughout is the same one the theming and lightness-ramp guides use: a plum/cream identity with a rose accent. Substitute your own swatches step by step.

New to Espalier entirely? Do Getting Started first — install, root setup, first component. This guide starts where that one ends.

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 are the designer-facing sentence that assigns the vocabulary: canvas (the page ground), ink (what reads on it), accent (links), action (filled buttons), structure (borders and rules). One line each:

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 these five lines into the full twenty-three-token semantic table, with APCA contrast enforced on every pairing. Reach for 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

This is the step that turns "close to the brand" into "the brand": sort the swatches by OKLCH lightness and set the ramp stops they naturally occupy to their exact measured values, then fill the gaps. The full procedure — with this brand's numbers derived swatch by swatch — is The Lightness Ramp:

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>

The imports you will not be writing yourself:

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.

Then the page is composition, not construction. esp-page kind="site" runs sections edge to edge and shares one content column (--esp-page-well-max-width) with the header and footer; each esp-section is a full-bleed band that centers its own well — give one the inverted context and it is the reversed band, nothing to neutralize; esp-stack and esp-row carry the flow inside; esp-header brings the brand, the nav, and the built-in scheme toggle (theme-toggle="visible"); esp-footer lays out 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">
      <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 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 is the receipt: for every semantic token, the color the theme asked for and the color that rendered, with every APCA adjustment named. 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 instead of a screenshot argument. 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