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>
Step 6 — Mount the shell: page, sections, header, footer
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 & 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.