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.
Aligning footer content to the surface
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:
- Keep main content between
--esp-page-main-min-width(default30rem) and--esp-page-main-max-width(thekindcap by default). - Resolve each open auxiliary pane's automatic target. Preview uses
--esp-page-preview-default-widthwhen 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. - 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-widthfirst, then preview toward--esp-page-preview-min-width. The same rule applies when only one auxiliary pane is open. - After every open auxiliary pane reaches its minimum, use
preview-collapse-sidebarto collapse a safely linked verticalesp-header/esp-menupair to its menu-button drawer when that releases useful workspace capacity. - Only after that optional rail has yielded, reclaim main width down to its configured minimum.
- 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:
- 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.
- Shift — when the right gutter is not quite wide enough, the
surface slides toward
startby exactly the shortfall (and no more) to free the difference, drawing first from the opposite gutter. - 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.
- Overlay drawer — below the mobile drawer threshold (
50em, the same one theesp-menudrawer uses) the flyout leaves the grid and becomes a fixed drawer over a vellum backdrop. Setmode="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-modalcomplementarylandmark 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.