Document Layout
A page read as a document — title, a fluid text column, a contents rail before it and a comment margin after it — on one grid with five tiers by its own width.
- Status
- Since
0.25.35- Accessibility pattern
- two labelled complementary landmarks, status skeleton
Last updated
Pricing v2
Updated 3m ago by Sanjay · 3 pages inside
Pages inside · 2
- Annual plan
- Add-ons
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Document Layout before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify document-layoutInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/document-layoutRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/badge, @vegastack/button, @vegastack/input-group, @vegastack/skeleton, @vegastack/item, @vegastack/use-overflow, @vegastack/sheet, @vegastack/tabs.
Usage
import {
DocumentLayout,
DocumentLayoutBody,
DocumentLayoutContents,
DocumentLayoutFoot,
DocumentLayoutHeader,
DocumentLayoutIcon,
DocumentLayoutMargin,
DocumentLayoutTitle,
DocumentLayoutTitleGroup,
} from "@/components/ui/document-layout";
<DocumentLayout margin={threadCount > 0 || drafting}>
<DocumentLayoutHeader>
<DocumentLayoutTitleGroup>
<DocumentLayoutIcon>📘</DocumentLayoutIcon>
<DocumentLayoutTitle>Pricing v2</DocumentLayoutTitle>
</DocumentLayoutTitleGroup>
<MetaLine>
<MetaLineItem>Updated 3m ago</MetaLineItem>
</MetaLine>
</DocumentLayoutHeader>
{outline.length > 1 ? (
<DocumentLayoutContents>
<TableOfContents
variant="rail"
side="start"
sticky={false}
items={outline}
/>
</DocumentLayoutContents>
) : null}
<DocumentLayoutBody>
<MarkdownView typeScale="document">{markdown}</MarkdownView>
</DocumentLayoutBody>
<DocumentLayoutFoot>{pagesInside}</DocumentLayoutFoot>
<DocumentLayoutMargin>{threads}</DocumentLayoutMargin>
</DocumentLayout>;DocumentLayout is the frame of a page people read and write as a document — a wiki page, a
spec, its public view and a version's full view all wear the same one. The text column is fluid:
about three quarters of the layout's width, between bounds that grow with each tier, so a large
monitor gets a generous column rather than a narrow strip. The title area sits above the text and
what follows it — sub-pages, the page's own comments — below, in the same column. Before the text,
in its gutter, a contents rail; after it, when there is room, a comment margin.
The layout follows its own width, not the window's (container queries in rem), so a collapsed sidebar or a split view lands in the right tier:
| Tier | Width | Gutters | Text | Contents | Comments |
|---|---|---|---|---|---|
| S | < 40rem | 0 | the full width | the host's menu or sheet | the host's sheet or popover |
| M | 40–64rem | 3rem | max(36rem, 75%) | the rail, in the gutter | a popover at the highlight |
| L | 64–90rem | 3rem | clamp(40rem, 75%, 52rem) | the rail, in the gutter | the margin (20rem) |
| XL | 90–120rem | 4rem | 60rem | the rail, in the gutter | the margin (24rem) |
| 2XL | ≥ 120rem | 4rem | 64rem | the rail, in the gutter | the margin (24rem) |
At the document type scale (14px Geist) that is about 94 characters a line at M's 36rem, 128 at L's 52rem, 150 at XL and 161 at 2XL; the 24px line height carries the longer lines.
The tier bounds are the system's layout tiers — tablet 40rem, laptop 64rem, wide 90rem
(LAYOUT_TIERS in page-layout) — plus a document-only 120rem step.
The rail, the text and the margin are one block, centred, and the text stays in the middle when the comment margin shows: the start side widens to mirror the margin, so wherever the width can hold the margin on both sides the text sits exactly where it sat without comments. With less room the start side gives way first, and the margin shifts the block towards the start only by what is missing; the text narrows only when the width cannot hold the text, the margin and the gutters at once.
It is one subtree at every width: a column with no room is display: none, never unmounted, so
an editor inside never remounts when the window resizes. margin shows the comment column from
L up; set it only while the column holds something (open threads beside the text, or a comment
being written), decided with the text so the first paint is right. A page's first comment keeps
its text in place while there is room for the margin on both sides.
From M up the grid keeps its start gutter whether or not a page has headings, and
DocumentLayoutContents sits in it, stacked above the text and the margin: a
TableOfContents variant="rail" side="start" there is a slim column of dashes that opens into the
labelled list over the text — so headings that arrive (or go) never move the text, and the open
list takes the pointer wherever it lies. Put the parts in reading order — header, contents, body,
foot, margin. Server-safe: the tiers are CSS.
Pricing v2
Updated 3m ago by Sanjay · 3 pages inside
Pages inside · 2
- Annual plan
- Add-ons
Anatomy
Examples
Wide
measure="wide" gives the text the whole width less the gutters (and the margin, where it
shows), up to 75rem (1200px), past which a line is too long to read — each reader's choice,
never the document's.
Pricing v2
Updated 3m ago by Sanjay · 3 pages inside
Aside
aside gives the layout a rail after the text: "discussion" (the comments) or "outline" (the
contents), and null while it is closed. A layout with an aside — even null — keeps the end
track for it from the L tier, so opening or closing the rail never moves or narrows the text; in
wide the text and an open aside share wide's min(100%, 1200px) block — the text keeps its
start edge and gives the aside its end. Put a
DocumentLayoutAside of each kind in the layout and the one aside names shows:
- Discussion — a non-modal rail as tall as the window: the heading with its
count, a × (onClose), andtabssuch as Open · Resolved · Page, plus Review while a review is open, each with an optional count. The page stays readable and editable beside it. It takes the comment margin's place, so passCommentMargin quote={false}for the cards beside the text and keep the quote in the rail. Below the L tier it is a bottom sheet instead, the whole screen below 640px. - Outline — the contents list, pinned from the XL tier while the discussion is closed; below XL the contents rail's hover ticks stay.
Leave aside unset and nothing changes: the margin and the rail behave as before.
Pricing v2
Updated 3m ago by Sanjay · 3 pages inside
<DocumentLayout
aside={discussion ? "discussion" : wideScreen ? "outline" : null}
>
…
<DocumentLayoutAside
count={open.length}
onClose={() => setDiscussion(false)}
tabs={[
{
value: "open",
label: "Open",
count: open.length,
content: <OpenThreads />,
},
{ value: "resolved", label: "Resolved", content: <ResolvedThreads /> },
{ value: "page", label: "Page", content: <PageThread /> },
]}
/>
<DocumentLayoutAside kind="outline">
<TableOfContents items={outline} sticky={false} />
</DocumentLayoutAside>
</DocumentLayout>Title
DocumentLayoutTitle is the document type scale's top step: 30/36 semibold in the heading face,
one step above the text's # (24/32). DocumentLayoutTitleGroup holds an optional
DocumentLayoutIcon over it, 4px apart: an emoji at the title's size, or the control that changes
it. reveal hides an "Add icon" control until the title group is hovered or holds focus (always
shown on a coarse pointer). The row is 32px whatever it holds, so showing, adding or removing
an icon moves nothing.
<DocumentLayoutTitleGroup>
<DocumentLayoutIcon reveal>
<Button variant="ghost" size="xs">
<SmilePlus aria-hidden />
Add icon
</Button>
</DocumentLayoutIcon>
<DocumentLayoutTitle>Pricing v2</DocumentLayoutTitle>
</DocumentLayoutTitleGroup>Meta lines
DocumentLayoutMeta is a line of small facts under the title — who updated the page, where it came
from, what links to it. Each DocumentLayoutMetaItem opens with a ·, and the row is drawn one
dot's width to the start of a clipped box, so a wrapped line never starts (or the line before it
ends) with a stray dot. An item takes an icon, and nowrap keeps a fact built around a link on one line (the link
truncates); DocumentLayoutMetaButton is a fact that opens
something ("Used in 2 tasks") and DocumentLayoutMetaLink a fact's link (render takes the
router's link).
variant="status" is the status row: one reserved line from sm and two on a phone, from the
first paint, so chips that stream in move nothing. Group its facts: a DocumentLayoutMetaGroup
(kind="unit") stays together — a DocumentLayoutMetaField ("Owner Sanjay") and its chip, wrapping
inside only when even the chip's short form doesn't fit — and a
kind="flow" group of flags flows with the row from sm; on a phone each group is a whole line.
A DocumentLayoutMetaBadge with label and short keeps all its words while the row has room;
on a tight row the chips drop to their short forms, the longest first (the whole label stays in
the chip's tooltip), and only if those don't fit either does the row wrap. Pass what the chips say
as watch, and DocumentLayoutMetaSkeleton while they stream in.
<DocumentLayoutMeta variant="status" watch={[status, review]}>
<DocumentLayoutMetaGroup>
<DocumentLayoutMetaField label="Owner">
Sanjay Desai
</DocumentLayoutMetaField>
<DocumentLayoutMetaBadge
variant="success"
label="Verified · Oct 14"
short="Verified"
>
<BadgeCheck data-icon="inline-start" aria-hidden />
</DocumentLayoutMetaBadge>
</DocumentLayoutMetaGroup>
<DocumentLayoutMetaGroup kind="flow">
<DocumentLayoutMetaItem>
<DocumentLayoutMetaBadge variant="warning">
2 out of date
</DocumentLayoutMetaBadge>
</DocumentLayoutMetaItem>
</DocumentLayoutMetaGroup>
</DocumentLayoutMeta>Where the contents rail has no room (the S tier), DocumentLayoutContentsButton is a round "On
this page" button floating at the window's bottom end — give it to TableOfContents as its
trigger. DocumentLayoutTitle takes editable when the title renames in place (the text cursor
over the whole row).
Page tools
The tools a page lays over or around its text, each without a class of your own:
DocumentLayoutFindBar— find in the text: a search field docked at the top of the text on a band of the page's background, with where the find stands ("3 of 12") and Previous, Next and Close (Enter, Shift+Enter, Escape). The host finds the matches; passhighlights(two CSS Custom Highlight names) and the bar paints them.DocumentLayoutJumpCue— "2 new comments ↓", a pill pinned to the bottom of the view while something new is out of sight; it takes no room.DocumentLayoutFloatingButton— a phone's one floating action ("Edit"), at the window's bottom end, clear of the home indicator.DocumentLayoutStarterswithDocumentLayoutStarterpills — ways to start an empty page ("Start from: Meeting notes · Spec · More templates…";morefor the quiet last one).DocumentLayoutNote— a quiet line under the text.DocumentLayoutDecisionBarwithDocumentLayoutDecisionBarTextandDocumentLayoutDecisionBarActions— a decision the reader owes the page ("Asha asked you to review this page · Approve"). Put it first inDocumentLayoutBody: it sticks under the top bar while the text is in view, and on a phone floats at the bottom with the floating buttons above it.DocumentLayout focusable— the canvas takes focus from a click in its read text (never a tab stop) and shows no focus cue of its own.
Sections, pages and live blocks
What a document holds besides its prose, again without a class of your own:
DocumentLayoutSection— a section at the foot ("Pages inside"), named by itsDocumentLayoutSectionTitle(countfollows it in the muted ink: "Pages inside · 3"), its title row aDocumentLayoutSectionHeaderwith an action at the end.DocumentLayoutSectionButtonis its quiet button:look="more"loads the rest ("Show more pages", "Load earlier"),look="add"is the one line an empty section shows to someone who can fill it.DocumentLayoutSectionSkeletonholds its place while it loads.DocumentLayoutPageListwithDocumentLayoutPageItemrows — the pages under a document, ruled above, between and below.DocumentLayoutPageLinkis a row's target: its emoji (icon, a page icon without one), name, one-lineexcerptandmeta, the whole row the link; a ⋯ menu follows it in the item.DocumentLayoutBlock— a live block drawn into the text (a task list): a card with aDocumentLayoutBlockHeader(DocumentLayoutBlockTitle, a ghost button at the end), thenDocumentLayoutBlockRowsofDocumentLayoutBlockRows (DocumentLayoutBlockRowMainwith aDocumentLayoutBlockRowRef, thenDocumentLayoutBlockRowMeta, which wraps under the main line below 640px), or aDocumentLayoutBlockNote("No tasks match") or aDocumentLayoutBlockSkeleton, and aDocumentLayoutBlockFooter.DocumentLayoutBlockActionis the link-styled action inside it (Try again, Open in Tasks →). A block of a table's records shows them as aDocumentLayoutBlockTable(columns, the name column first): aDocumentLayoutBlockTableHeadrow ofDocumentLayoutBlockTableColumns, then aDocumentLayoutBlockTableBodyofDocumentLayoutBlockTableRows ofDocumentLayoutBlockTableCells (primaryon the name column's, a row header). It is read-only, every row's columns line up, and it scrolls sideways when its columns don't fit.
Pricing v2
Updated 3m ago by Sanjay · 3 pages inside
Skeleton
DocumentLayoutSkeleton is the page's loading state on the SAME grid and tier — an icon row
(icon), a title line, metaLines meta lines (default 1) and paragraphs of text bars at the
document line pitch (a 14px bar every 24px, 16 every 26 on a coarse pointer), 12px between
paragraphs — so nothing moves when the page arrives. Pass it the margin, measure, icon and
metaLines the page will have. It is one status region, named by label.
The tier in script
Branches CSS cannot take — comments in the margin or in a popover, the contents on the rail or
in a menu — read the tier with useDocumentLayoutTier(ref). It reads --document-layout-tier, which
the container queries set, so script and stylesheet never disagree about where a tier starts.
fallback (default "xl") is the answer on the server and before the first measurement.
import { useDocumentLayoutTier } from "@/components/ui/document-layout";
const ref = React.useRef<HTMLDivElement>(null);
const tier = useDocumentLayoutTier(ref);
<DocumentLayout ref={ref} margin>
…
{tier !== "s" && tier !== "m" ? (
<DocumentLayoutMargin>{threads}</DocumentLayoutMargin>
) : null}
</DocumentLayout>;A document prints as one column of its header, body and foot — the contents and the comment margin
drop out — on a named page with a 16mm × 14mm paper margin, in the light palette whatever the
reader's theme. With typeScale="document", MarkdownView and TextEdit add the text's own
print rules: code wraps, tables print in full, highlights and controls drop out, and an external
link prints its address.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aside | "discussion" | "outline" | null | — | The aside after the text: "discussion" (the comments rail), "outline" (the contents,
pinned from the XL tier — below it the contents rail's hover ticks stay) or null (closed).
Giving it at all, even null, keeps the end track for the aside from the L tier, so opening
or closing it never moves the text (in wide, an open aside takes its width out of wide's
1200px block and the text keeps its start edge). Put a
DocumentLayoutAside of each kind in the layout; the one named here shows. An open
discussion takes the comment margin's place; the pinned outline replaces the contents rail.
Leave it unset for a layout with no aside (the margin and rail behave as before). |
focusable | boolean | false | The canvas takes focus from a click in its read text (tabIndex={-1}, never a tab stop), so
a host knows the reader is in the page; it shows no focus cue of its own (the text does). |
margin | boolean | false | Show the comment margin (from the L tier): set it only while the margin holds something — open threads beside the text or a comment being written — and decide it with the text (on the server, from the thread count), so the first paint is right. The text stays centred either way while the width holds the margin on both sides; below that the margin shifts the block towards the start. |
measure | "document" | "wide" | "document" | The text column: document (fluid, about three quarters of the layout within each tier's
bounds) or wide (the whole width less the gutters and the margin, up to 75rem — 1200px;
each reader's own choice, never the document's). |
Data attributes and CSS variables on DocumentLayout
| Attribute | Values |
|---|---|
data-aside | mirrors a prop or state value |
data-margin | "" |
data-measure | mirrors a prop or state value |
data-slot | "document-layout" | "document-layout-grid" |
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | What the aside shows when it has no tabs. |
closeLabel | string | "Close" | The × button's name. |
count | number | — | A count after the title — the open threads. |
defaultValue | string | the first tab | The tab selected first, uncontrolled. |
kind | "discussion" | "outline" | "discussion" | Which aside this is: discussion (the comments rail; a bottom sheet below the L tier) or
outline (the contents, pinned from the XL tier). It shows while DocumentLayout's aside
names it. |
onClose | (() => void) | — | Close the aside (set the layout's aside to null): a × in the heading, and the sheet's
own close below L. Without it the aside has no close control. |
onValueChange | ((value: string) => void) | — | Called when the reader picks a tab. |
tabs | readonly DocumentLayoutAsideTab[] | — | Tabs under the heading, in order — Open · Resolved · Page, and Review while a review is open.
Without tabs the aside shows children. |
title | string | "Comments" (discussion), "On this page" (outline) | The heading, and the region's name. The outline shows no heading unless one is given (its
TableOfContents carries its own title); its region is still named. |
value | string | — | The selected tab, controlled. Pair with onValueChange. |
Data attributes and CSS variables on DocumentLayoutAside
| Attribute | Values |
|---|---|
data-kind | mirrors a prop or state value |
data-slot | "document-layout-aside" | "document-layout-aside-body" | "document-layout-aside-close" | "document-layout-aside-count" | "document-layout-aside-header" | "document-layout-aside-panel" | "document-layout-aside-panel-frame" | "document-layout-aside-sheet" | "document-layout-aside-tab-count" | "document-layout-aside-tablist" | "document-layout-aside-tabs" |
| Prop | Type | Default | Description |
|---|---|---|---|
aside | "discussion" | "outline" | null | — | The aside, as the page will (its track, so the text lands where the page puts it). |
icon | boolean | false | Reserve the icon row above the title (32px and the group's 4px), as a page whose
DocumentLayoutTitleGroup holds a DocumentLayoutIcon will — an editable page always does. |
label | string | "Loading page" | The loading state's accessible name. |
lines | number | 8 | How many lines of text to sketch, in paragraphs of 3, 2 and 3 lines. |
margin | boolean | false | Reserve the comment margin, as the page will. |
measure | "document" | "wide" | "document" | The text column, as the page will. |
metaLines | number | 1 | How many 20px meta lines to sketch under the title (8px apart), as the page's header has. |
Data attributes and CSS variables on DocumentLayoutSkeleton
| Attribute | Values |
|---|---|
data-slot | "document-layout-skeleton" | "document-layout-skeleton-line" |
| Prop | Type | Default | Description |
|---|---|---|---|
reveal | boolean | false | Hidden until the title group is hovered or holds focus (a fine pointer only; a coarse pointer always sees it) — for an "Add icon" control on a page that has none. It keeps its row, so showing it moves nothing. |
Data attributes and CSS variables on DocumentLayoutIcon
| Attribute | Values |
|---|---|
data-reveal | "" |
data-slot | "document-layout-icon" |
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "line" | "status" | "line" | line — facts that wrap freely. status — the status row: one reserved line from sm, two
on a phone, its chips fitted to the width (short forms, then wrapping). |
watch | readonly unknown[] | [] | status only: what the chips say — the row is fitted again whenever any of these change (it
is also fitted on every width change and once web fonts arrive). |
Data attributes and CSS variables on DocumentLayoutMeta
| Attribute | Values |
|---|---|
data-slot | "document-layout-meta" | "document-layout-meta-row" |
data-variant | mirrors a prop or state value |
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The chip's whole label; with short, its words give way to the short form on a tight status
row (and the chip's title defaults to it). Without, the chip shows its children and keeps
its size. |
short | string | — | The short form, a prefix of label ("Verified"). |
variant | "default" | "destructive" | "ghost" | "info" | "link" | "outline" | "secondary" | "success" | "warning" | null | — |
Data attributes and CSS variables on DocumentLayoutMetaBadge
| Attribute | Values |
|---|---|
data-fitted | "" |
| Prop | Type | Default | Description |
|---|---|---|---|
hasMatches* | boolean | — | There are matches to step through (Previous and Next are disabled without). |
onClose* | () => void | — | Closes the find (also Escape in the field). |
onNext* | () => void | — | The next match (also Enter in the field). |
onPrevious* | () => void | — | The previous match (also Shift+Enter in the field). |
onValueChange* | (value: string) => void | — | Called with the query as it is typed. |
value* | string | — | The query. |
autoFocus | boolean | false | Focus the field when the bar mounts. |
className | string | — | Classes for the bar. |
highlights | { all: string; current: string; } | undefined (the host paints its own) | The CSS Custom Highlight names the host paints its matches under: every match on a light primary tint, the current one in the primary ink. |
label | string | "Find in page" | The bar's accessible name and the field's. |
status | string | — | Where the find stands ("3 of 12", "No matches"); nothing while the query is empty. |
Data attributes and CSS variables on DocumentLayoutFindBar
| Attribute | Values |
|---|---|
data-slot | "document-layout-find-bar" | "document-layout-find-status" |
| Prop | Type | Default | Description |
|---|---|---|---|
count | React.ReactNode | — | How many the section holds, after the title in the muted ink ("Pages inside · 3"). |
Data attributes and CSS variables on DocumentLayoutSectionTitle
| Attribute | Values |
|---|---|
data-slot | "document-layout-section-title" |
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
look | "add" | "more" | "more" | more — a quiet ghost button at the start of the section ("Show more pages", "Load
earlier"); add — the bare line a section is while it holds nothing ("+ Add a page inside"),
muted until hovered, with no fill. |
Data attributes and CSS variables on DocumentLayoutSectionButton
| Attribute | Values |
|---|---|
data-look | mirrors a prop or state value |
| Prop | Type | Default | Description |
|---|---|---|---|
title* | React.ReactNode | — | The page's name: it takes the row before the excerpt does, cut short to one line. |
excerpt | React.ReactNode | — | One line of its text, after the name from 640px, under it below. |
highlighted | boolean | — | |
icon | string | null | — | The page's own icon (an emoji); a page icon when it has none. |
meta | React.ReactNode | — | What closes the row, small and muted (when it changed). |
Data attributes and CSS variables on DocumentLayoutPageLink
| Attribute | Values |
|---|---|
data-slot | "document-layout-page-link" |
DocumentLayoutHeader, DocumentLayoutTitleGroup, DocumentLayoutBody and DocumentLayoutFoot
take a div's native props, DocumentLayoutTitle an h1's; DocumentLayoutContents and DocumentLayoutMargin take an aside's. What the contents
hold sticks --document-layout-sticky-top (default --spacing(8)) below the top of the scroll
container. The discussion rail sticks there too, as tall as the window less
--document-layout-aside-offset (default --spacing(24): the bar above and the offsets).
DocumentLayoutAsideTab is { value, label, count?, content }.
Accessibility
- The contents and the margin are two
asidelandmarks, named "Contents" and "Comments" (override witharia-label). Below their tier they aredisplay: none, so they leave the accessibility tree with the screen. - The aside is an
asidelandmark named by itstitle("Comments", "On this page"); its tabs are a Base UI tab list named the same, and the × is a "Close" button. It is non-modal: focus stays where the reader puts it, and the page beside it keeps working. Below L the discussion is a modal bottom sheet with its own close. - Source order is reading order: the title area, the contents, the text, what follows it, then the comments — whatever the columns look like.
- The skeleton is one
statusregion named "Loading page" witharia-busy; its bars are hidden.
| Key | Action |
|---|---|
| Tab | Move through the contents, the text's links, then the margin or aside |
| ← / → | Move between the aside's tabs |
| Esc | Close the discussion sheet (below L) |
| Contract | States tested |
|---|---|
| Behaviour | tier-s, tier-m, tier-l, tier-xl, tier-2xl, wide, loading, aside-discussion, aside-outline, aside-sheet |
| Accessibility | labeled, browser-accessibility-test |
| Visual | default, loading |