Page Alignment, Canvas, and Surface

Once a page is wider than its content cap, esp-page splits into two layers. The surface is the constrained frame where content lives — the left aside, the main content well, and the right aside, together. The canvas is whatever viewport width remains once the surface reaches its cap: the outer gutters on either side of the surface.

Below the cap there is no surplus width, so there are no gutters and no canvas — the surface fills the viewport exactly as it always has. The alignment, canvas, and surface features described here only differentiate a page once it is wider than its cap.

Aligning the surface

The align attribute places the surface within the page by weighting the canvas gutters. It has three values:

align Behavior
start (default) The surface hugs the leading edge; all surplus width collects in the trailing gutter. This reproduces the historical layout.
center Surplus is split evenly between both gutters, centering the surface.
end The surface hugs the trailing edge; surplus collects in the leading gutter.
<esp-page align="center">
  <esp-header slot="header">…</esp-header>
  <!-- main content -->
</esp-page>

The main-well cap is --esp-page-main-max-width, with the legacy --esp-page-max-width as its fallback. The kind attribute supplies the default (1536px for wide, 768px for narrow, and unbounded for full). An explicit --esp-page-main-max-width overrides every kind default, so a kind="full" authoring workspace can opt back into a finite main well and use align normally.

Styling the canvas

The canvas gutters default to transparent, so they show the page background and look like part of the surface until you style them. Give them their own background to promote the spare width from dead space to a designed region:

esp-page {
  --esp-page-canvas-background: var(--esp-color-layer-1);
  --esp-page-canvas-background-image: url(/assets/texture.png);
  --esp-page-canvas-background-image-opacity: 0.4;
}

The image knobs mirror the page background-image tokens, and the opacity applies only to the image layer so it does not fade the background color.

The surface edge

A soft left/right shadow on the surface separates it from the canvas. It is on by default, and because it casts outward into the gutters — and esp-page clips horizontal overflow — it is only visible once real gutters exist. Below the cap the surface fills the viewport and the shadow is clipped away.

Tune or remove the shadow, or switch to a hairline border, with the surface tokens:

esp-page {
  /* Remove the default shadow… */
  --esp-page-surface-shadow: none;
  /* …and use a border instead. */
  --esp-page-surface-border: 1px solid var(--esp-color-border);
}

The main content well is transparent by default, so it shows the page background and the surface shadow frames it. Give the well its own card color with --esp-page-main-background, or keep it transparent and turn the shadow off for content that floats directly on the page with no frame:

esp-page {
  /* A distinct content card… */
  --esp-page-main-background: var(--esp-color-layer-1);
  /* …or no frame at all: */
  --esp-page-main-background: transparent;
  --esp-page-surface-shadow: none;
  --esp-page-surface-border: none;
}

Aligning header content to the surface

A full-bleed header on a wide page used to leave the brand and the menu stranded at opposite ends of a mostly empty bar. When an esp-header is slotted into an esp-page, it now constrains its brand/menu/buttons row to the surface and aligns it to match the page's align, while the header bar background stays full-bleed. This happens automatically — no markup change is needed.

esp-page publishes the contract as two custom properties the header reads: --esp-header-content-max-width (the surface cap) and --esp-header-content-lead (the alignment). To let a particular header span the full width instead — its previous behavior — set full-bleed-content on it:

<esp-page align="center">
  <esp-header slot="header" full-bleed-content>…</esp-header>
</esp-page>

Headers used on their own, outside an esp-page, receive no contract and remain full-bleed.

esp-footer uses the same visual treatment with footer-owned contract names. Its color or artwork spans the full footer bar, while its brand, link groups, aside, and legal content line up with the page surface automatically:

<esp-page align="center">
  <!-- page content -->
  <esp-footer slot="footer" brand-text="Example" columns="3">
    <esp-footer-link-group heading="Explore">
      <a href="/products">Products</a>
      <a href="/about">About</a>
    </esp-footer-link-group>
  </esp-footer>
</esp-page>

The page publishes --esp-footer-content-max-width and --esp-footer-content-lead to a directly slotted esp-footer. Keep the component itself on slot="footer"; a wrapper such as <div slot="footer"><esp-footer>…</esp-footer></div> avoids nested footer landmarks, but the nested component cannot receive the page's direct surface-alignment contract. Standalone footers fall back to their full available width. Add full-bleed-content when both the footer background and its content should span the complete bar.

The contained shell

By default the header and footer bars span the full viewport while the content is constrained. Add the contained attribute to instead seat the header and footer on the surface, so the whole page reads as one band floating on the canvas:

<esp-page contained align="center">
  <esp-header slot="header">…</esp-header>
  <!-- main content -->
  <esp-footer slot="footer" brand-text="Example">…</esp-footer>
</esp-page>

In a contained page:

  • the header and footer are the surface width, so header content (brand, menu, buttons) lines up with the content edges automatically — no full-bleed bar behind them;
  • the canvas gutters run the full height of the page beside the band;
  • the surface edge shadow is one continuous line from the header through the content to the footer, with no break at the region joins.

Below the cap there are no gutters, so a contained page fills the viewport and looks the same as the default. contained is designed for header-position="normal" or "sticky"; a fixed header is always full-bleed and cannot be banded.

The persistent preview surface

Place preview content in the preview slot and set preview-open to expose a persistent authoring surface. When in-grid help is also open, the logical inline order is always main → preview → help. Use the right slot for content that must remain inside the main surface, the flyout slot for transient requested help, and preview for the persistent rendered result.

The preview begins at viewport top, flush beside the application header. It stays visible while the main page scrolls, and its content scrolls independently inside a 100dvh-high region. Wheel and trackpad motion scrolls preview first, then chains back to the main document at either boundary or immediately when preview has no intrinsic overflow. This is the default behavior; consumers do not need to style the preview part or any shadow-DOM wrapper.

Preview is also a complete secondary surface by default. It uses the main/page background, a dotted leading tear-off edge, and the page's trailing edge shadow. While it is visible, the main surface stops casting its own shadow so the two regions read as additive rather than as overlapping cards. Override --esp-page-preview-background, --esp-page-preview-border, or --esp-page-preview-shadow when the preview needs a different treatment.

The workspace negotiates width in this order:

  1. Keep main content between --esp-page-main-min-width (default 30rem) and --esp-page-main-max-width (the kind cap by default).
  2. Resolve each open auxiliary pane's automatic target. Preview uses --esp-page-preview-default-width when set, clamped between its preview min/max tokens; help does the same with --esp-page-flyout-default-width. When a default is unset, that pane retains the established flexible policy: preview grows toward its maximum first, then help grows toward its maximum.
  3. Use spare trailing canvas while keeping main at its natural width. If the requested targets do not fit there, shrink help toward --esp-page-flyout-min-width first, then preview toward --esp-page-preview-min-width. The same rule applies when only one auxiliary pane is open.
  4. After every open auxiliary pane reaches its minimum, use preview-collapse-sidebar to collapse a safely linked vertical esp-header / esp-menu pair to its menu-button drawer when that releases useful workspace capacity.
  5. Only after that optional rail has yielded, reclaim main width down to its configured minimum.
  6. If a preview-only layout still cannot fit, hide only rendered preview. If open help is what makes the complete minimums impossible, promote help to its modal overlay drawer instead of clipping it. The in-grid layout restores automatically when every requested minimum fits again.

The logical preview-open state remains set when space temporarily disappears, so the preview returns automatically when the viewport grows. The managed preview-visible attribute reports whether it is rendered, and preview-reclaiming reports when it is consuming layout space that would otherwise belong to the main well.

For example, an authoring workspace can request large starting panes while retaining independent resize travel and compact-container fallbacks:

esp-page.authoring-workspace {
  --esp-page-main-min-width: 22.5rem;
  --esp-page-main-max-width: 1536px;

  --esp-page-preview-min-width: 22.5rem;
  --esp-page-preview-default-width: 1024px;
  --esp-page-preview-max-width: 1536px;

  --esp-page-flyout-min-width: 20rem;
  --esp-page-flyout-default-width: 624px;
  --esp-page-flyout-max-width: 50vw;
}

Open the full-page workspace lab to exercise this allocation with a linked navigation rail, resizable preview, compact field help, and viewport-height full-document help outside a bounded WTFM example.

These defaults are responsive allocation targets for the canvas left after main reaches its natural maximum width. They follow the same settlement path as a retained user preference, while a retained preference takes precedence for that pane. If the available canvas cannot hold both targets, help and then preview shrink toward their minimums; main reclaims toward its minimum only when the auxiliary minimums cannot fit, not merely to preserve a target.

The visible preview/help allocation occupies its own exact track before the trailing outer gutter, so additional canvas stays outside the composite rather than stretching it. No pane can grow beyond its configured maximum; even when the viewport exceeds the sum of all visible pane maximums, the composite stays capped. With align="center", the leading and trailing outer gutters are equal, centering main + preview + in-grid help together in both LTR and RTL.

Resizing the workspace

Add workspace-resizable when an author should be able to choose the pane proportions. esp-page turns the existing dotted Main/Preview seam into a transparent drag target and adds a second target to the Preview/Help seam while both auxiliary panes are in-grid. When the last visible auxiliary pane does not abut the logical trailing page edge, its exposed outer edge becomes a third resize target: preview owns it when help is closed, and help owns it while help is in-grid:

<esp-page preview-open workspace-resizable>
  <main>Editor</main>
  <article slot="preview">Rendered preview</article>
  <esp-flyout slot="flyout">Contextual help</esp-flyout>
</esp-page>

Each target is a focusable vertical separator. Drag it, or focus it and use Left/Right Arrow; hold Shift for the larger step. Arrow keys move the seam in the physical direction named by the key, including in RTL. The target is wide enough to acquire comfortably, but remains transparent so the dotted seam does not get visually heavier. Keyboard focus draws a short, viewport-centered outline. While preview is visible, each seam trades width with its neighbors and presses on through preview's minimum: the Main/Preview seam resizes main against preview first, then pushes the remaining travel into open in-grid help down to help's minimum; the Preview/Help seam grows help against preview first, then reclaims main down to main's minimum (help's own maximum still caps it). In the other direction, Preview/Help first uses trailing outside room: it grows preview and pushes Help outward at its current width, then contracts Help only after it reaches the page edge. Main/Preview can similarly grow main and push the complete Preview/Help group outward while main has growth capacity. Once main reaches its maximum, however, Main/Preview immediately contracts preview and releases the width as outside surplus; it never translates a capped main merely because trailing room remains. When preview is hidden or closed, the second seam sits directly between main and help and follows the same main-cap rule while sizing help against main. The Main/Preview seam does not stop when main reaches its maximum: continued movement toward preview keeps main capped, shrinks preview toward its own minimum, and releases the difference into the aligned outer canvas. On a centered page, the smaller capped composite remains centered. The exposed outer edge resizes its pane directly without requiring an adjacent pane to grow or shrink. Dragging that edge to the page's logical trailing edge consumes the complete outside canvas — both gutters on a centered page — and maximizes the pane within that space, unless its max-width token stops the edge first.

During pointer resizing, a centered or end-aligned page owns the composite workspace's logical leading position. It stays fixed for zero-sum pane trades and adjusts only as the composite grows or shrinks through outside canvas. The active separator therefore stays under the pointer even after the composite becomes narrower or wider than its starting size, including through the points where main reaches its maximum or the last visible pane reaches the page edge. Start alignment never borrows trailing surplus to translate capped leading panes: once main is capped, the seam may detach from the pointer while Preview continues shrinking. On release, a held page glides the complete workspace back to its align position; a centered page rebalances the remaining outside gutters only after the author has chosen the pane size. Preview and help use the same trailing-edge and settlement behavior, and the logical interaction mirrors in RTL.

Keyboard focus draws a dashed line along the focused seam itself — the visible height of the dotted tear-off edge, up to one viewport — with a soft shadow. Theme those interactions with --esp-page-resize-step (default 1rem), --esp-page-resize-large-step (4rem), --esp-page-resize-handle-hit-size (2.75rem), --esp-page-resize-focus-outline, and --esp-page-resize-focus-shadow.

The page retains preferred preview/help widths for as long as that element is mounted. A retained preference supersedes that pane's default-width token and is an input to the normal workspace allocator, not a direct grid override. Configured defaults are re-clamped to the canvas remaining after main reaches its configured maximum and never shrink main merely to preserve their target. A retained user choice is stronger: it is re-clamped to the complete auxiliary capacity available while main remains at or above its minimum, regardless of whether minimum-fit reclamation was already required. Navigation can still collapse, preview can still hide, and help can still promote to its overlay drawer. Expanding the container restores the retained preference as far as the current bounds allow, including after a responsive hide or overlay handoff. The Main/Preview handle is removed from interaction when preview is hidden. The trailing-edge handle follows whichever auxiliary pane is last in-grid, is hidden when that pane abuts the page edge, and all workspace handles are removed while help presents as an overlay drawer.

With preview-collapse-sidebar and a valid linked vertical menu/header pair, responsive settlement contracts help and preview to their minimums before it switches the navigation rail into the header button/drawer presentation, and only then reclaims main below its natural or configured maximum. A successful collapse remains in effect after pointer release, keyboard settlement, and observer updates whenever restoring the rail would reduce a retained preview or help width, shrink main further, or otherwise change the settled allocation. The rail returns after viewport expansion or pane contraction creates enough room to preserve the same main, preview, and help widths. Recovery includes a small stable margin so layout observation cannot oscillate between rail and drawer presentations at the threshold. Closing preview and help also releases the page's collapse request.

Each separator announces a pane width that keeps changing across its complete travel, including the floor cascades: the Main/Preview separator publishes main's width, and the second separator publishes help's width in both of its arrangements. The trailing-edge separator publishes the width of the preview or help pane that owns it. When main is already capped, the first separator's value text also announces preview's changing width while its numeric main value remains at that cap; while the second separator moves Help outward or later contracts it, its value text announces both Preview and Help widths. Keyboard adjustments never re-negotiate mid-interaction — the focused separator stays rendered and focused across repeated Arrow steps, and the full validation pass runs after a short keyboard-idle window or when focus leaves it. This lets container and media changes re-clamp a workspace even when focus remains parked on the separator; a pointer session settles on release the same way.

Listen for esp-page-workspace-resize to observe author changes. Its detail is { separator, source, previewWidth, flyoutWidth }, where separator is "main-preview", "preview-flyout", or "main-flyout" for the internal seams, and "preview-end" or "flyout-end" for the exposed trailing edge. source is "pointer" or "keyboard". The width fields report the panes' current allocated CSS-pixel widths; previewWidth is zero while preview is hidden and flyoutWidth is zero while help is closed. The retained preferences remain an internal allocator input and can restore when those panes return. The event bubbles and crosses the shadow boundary. The supported styling hooks are ::part(main-preview-resize-handle), ::part(preview-flyout-resize-handle), ::part(main-flyout-resize-handle) for the same physical seam in its main/help arrangement, and ::part(preview-end-resize-handle) / ::part(flyout-end-resize-handle) for the exposed outer edge; applications never need to query the page shadow root.

Navigation collapse is deliberately conservative. It is available only when the directly slotted esp-header and esp-menu use the same drawer-target / id link and the menu is vertical. Once collapsed, the rail stays hidden whenever its saved width is what allows main + preview + help to fit. It is restored only when that complete layout can honor every minimum with the rail present. This avoids hiding preview merely to bring back navigation that already has an accessible burger/drawer presentation. Other navigation arrangements remain untouched.

The preview and in-grid help have independent state and may be shown or hidden in any combination. The preview yields visually to an overlay flyout and is hidden at the page's compact mobile breakpoint, but its logical request remains open and restores after the overlay closes. Normal, sticky, and fixed application headers do not offset preview: its sticky top is always 0.

The auto flyout fallback uses visible page geometry, not viewport width alone. It intersects the page with the visual viewport and any clipping ancestors, so an oversized layout box cannot leave help open beyond the visible edge. Preview and help widths are allocated from that same visible geometry, so a layout box slightly wider than the viewport — a 100vw page beside a classic scrollbar — narrows help by the hidden sliver instead of promoting it to a drawer. Even above 50em, sidebar + main minimum + help minimum can exceed a narrow page or embedded container. In that case help uses the same accessible overlay drawer as mobile/mode="overlay": dialog semantics, focus trap, inert background, and scroll locking all move with the visual presentation. A page-managed drawer stays on that stable rung until visible width grows beyond the width that caused promotion; the scrollbar-width change caused by scroll locking cannot make it oscillate. Nested drawers also raise their containing page contexts so their vellum and close control paint above ancestor page headers.

Anchored help keeps its caret at the main/preview edge. When preview lies between that caret and the help surface, a non-interactive dotted line at 40% opacity bridges the preview and ends at a short, thick, caret-height marker on the preview/help seam. The bridge follows the actual negotiated preview width and anchor marker; applications do not need to draw or position it. When preview is closed or hidden, both the bridge and its thick seam terminus disappear because help is attached directly to main.

Use showPreview(), closePreview(), or togglePreview() for imperative control. Help retains its own show(), close(), and toggle() methods. Set preview-label to give the complementary landmark an accessible name. Theme the region bounds with the six main/preview/flyout min/max tokens and seed the two auxiliary allocations with the preview/flyout default-width tokens. Theme the secondary surface with the three preview surface tokens, and use the preview / preview-content parts only for content-specific styling.

The Page component example starts with preview visible and help closed, avoiding an unsolicited modal on narrow documentation viewports. Its two buttons still demonstrate every preview/help combination.

<esp-button id="preview-toggle" label="Toggle preview"></esp-button>
<esp-button id="help-toggle" label="Toggle help"></esp-button>
<esp-page
  id="editor-page"
  preview-label="Page preview"
  preview-collapse-sidebar
  workspace-resizable
  style="
    --esp-page-main-min-width: 44rem;
    --esp-page-main-max-width: 56rem;
    --esp-page-preview-min-width: 22.5rem;
    --esp-page-preview-default-width: 36rem;
    --esp-page-preview-max-width: 42rem;
    --esp-page-flyout-min-width: 20rem;
    --esp-page-flyout-default-width: 24rem;
    --esp-page-flyout-max-width: 28rem;
  "
>
  <main>Long, independently scrolling editor</main>
  <aside slot="preview">Long, independently scrolling rendered page</aside>
  <esp-flyout id="editor-help" slot="flyout" heading="Help">Contextual help</esp-flyout>
</esp-page>

<script>
  const page = findById("editor-page");
  findById("preview-toggle").addEventListener("esp-clicked", () => {
    page.togglePreview();
  });
  findById("help-toggle").addEventListener("esp-clicked", () => {
    findById("editor-help").toggle();
  });
</script>

The flyout surface

The canvas exists to carry designed spare width. An esp-flyout in the page's flyout slot is a transient panel for help and more-info content. When preview is visible it follows preview; otherwise it sits directly after the main surface:

<esp-page align="center">
  <esp-flyout slot="flyout" heading="More info">
    <p>Contextual detail lives here.</p>
  </esp-flyout>
  <!-- main content -->
</esp-page>

Closed, the flyout costs nothing — its grid track is zero width and the page renders exactly as if the slot were empty. Open, the page places it by a ladder of modes, from widest viewport to narrowest:

  1. Gutter — the flyout lives in the right canvas gutter between its configured minimum and maximum. When the gutter already has room, the panel occupies spare canvas and the content does not move at all.
  2. Shift — when the right gutter is not quite wide enough, the surface slides toward start by exactly the shortfall (and no more) to free the difference, drawing first from the opposite gutter.
  3. Docked sidebar — once that gutter is gone too, the flyout competes for width with the main well and the content narrows, but is never covered.
  4. Overlay drawer — below the mobile drawer threshold (50em, the same one the esp-menu drawer uses) the flyout leaves the grid and becomes a fixed drawer over a vellum backdrop. Set mode="overlay" on the flyout to force this presentation at every width. Here — and only here — the flyout is a true modal: role="dialog", the background goes inert, scroll is locked, and focus moves into the drawer and is trapped (restored to the trigger on close). The in-grid modes stay a non-modal complementary landmark that never steals focus.

Where the browser supports grid track interpolation, the handoff between the in-grid modes animates; elsewhere the modes still apply without the glide, and prefers-reduced-motion disables it entirely.

The flyout lives on the canvas, outside the surface: the canvas paints beneath the panel and the surface edge shadow separates the content from it, so the panel reads as its own furniture rather than part of the content. By default it is styled as a tear-off piece — a dotted perforation on the leading edge (--esp-flyout-border: 1px dotted var(--esp-color-border)), rounded trailing corners (--esp-flyout-radius, from the global --esp-size-border-radius), and no shadow. Every part of that look is a token: swap the perforation for a solid hairline, square the corners, or restore a shadow with --esp-flyout-shadow.

To make the panel read as a raised peer of the surface it flies out from rather than a flat tear-off, add match-surface. It lifts the panel's trailing edge with the surface's edge shadow while keeping the leading edge attached at its perforation with no shadow — so the panel sits on the page like the content surface does, without a shadow bleeding over the content it is attached to:

<esp-flyout slot="flyout" match-surface>…</esp-flyout>

The right aside and the flyout are complementary, not alternatives: the right slot is persistent furniture that lives on the surface; the flyout is transient — opened, swapped, and dismissed at runtime. Escape closes it in every mode. In the in-grid modes opening never moves focus; its content scrolls first and hands boundary motion back to the main document, including when it has no intrinsic overflow. In the overlay-drawer mode the flyout is a modal, so focus moves into the drawer, the main document stays locked, and focus returns when it closes.

Anything that cannot know where the flyout lives — a form item, a help button, dialog content — can request it over the bus, the same pattern showToast() uses:

import { showFlyout } from "@taprootio/espalier";

showFlyout({ heading: "Details", content: detailsFragment });

Pass the triggering element as anchor when the panel should begin at that control instead of the top of the content row. In-grid flyouts stay in document flow and scroll with their anchor. If a panel would cross the visible scrollport's bottom edge, it shifts upward only far enough to fit; as the page scrolls and its natural trigger-aligned position fits again, the shift returns to zero. A panel with more content than the scrollport can grow to its full height and then scrolls that content internally. The mobile/forced overlay drawer remains fixed to the viewport:

const trigger = document.querySelector<HTMLElement>("#show-details")!;
showFlyout({ heading: "Details", content: detailsFragment, anchor: trigger });

A second showFlyout() swaps the panel's content in place; it never stacks. The bus is a global broadcast, so a single flyout in the page answers showFlyout() with no configuration — but every listening flyout answers it. If a page has more than one flyout (say a preview panel you drive directly), mark the ones that should not be the shared help surface standalone, leaving one to service the bus. See the Flyout component page for the full API, events, and tokens.

Compatibility

None of this changes a page that is narrower than its cap: with no surplus width there are no gutters, the surface fills the viewport, the edge shadow is clipped, and the header spans the full bar — pixel for pixel what it rendered before. Above the cap the defaults do change on purpose: the surface gains its edge shadow and the header content lines up with the surface. Opt back out with --esp-page-surface-shadow: none and full-bleed-content if you need the flat, full-bleed look everywhere.

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