Layout
The scroll contract, page grammar, sidebar behaviour and responsiveness.
The scroll contract
This is the most important layout rule in the system, and the one that is invisible until it breaks.
The host is a fixed-height grid whose content row is overflow: hidden:
<div class="bg-background grid h-screen grid-rows-[auto_1fr] overflow-hidden">
<nav class="h-10">…</nav>
<main class="relative min-h-0 overflow-hidden">
<iframe class="block size-full border-0"></iframe>
</main>
</div>An embedded app therefore gets a viewport, not a page. There is no ambient
document scroll to inherit. If your app lets <body> grow, the iframe does not
grow with it — content is simply cut off, and the user has no scrollbar to
reach it.
Rule. The app owns an internal scroll container. The document never scrolls.
<div class="h-svh overflow-hidden"> <!-- root: exactly one viewport -->
<aside>…sidebar…</aside>
<div class="flex min-h-0 flex-col"> <!-- min-h-0 so the child can shrink -->
<header class="flex h-10 shrink-0 items-center gap-2 border-b px-2">…</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- page content scrolls here -->
</div>
</div>
</div>Three details that are easy to get wrong:
h-svh, noth-screen. The small-viewport unit accounts for mobile browser chrome;100vhoverflows on iOS Safari.min-h-0on every flex child in the chain. Flex items default tomin-height: auto, which refuses to shrink below content size — theoverflow-y-autothen never activates and the whole column grows instead. This is the single most common cause of a broken scroll container.shrink-0on the header. Otherwise it compresses when content is tall.
Use native overflow-y-auto. A JavaScript scroll-area component costs you
momentum scrolling, find-in-page and scroll anchoring for a styled scrollbar
you do not need at zero radius.
The console's own pages follow the same contract with a simpler shell, since
they have no sidebar: h-full overflow-auto p-8 on the route root.
Page grammar
Three primitives compose every page. They are small enough that the markup is the specification — implement them in whatever your framework calls a component.
Page
The content column. Two widths only.
<div class="mx-auto flex w-full flex-col gap-6 p-6 max-w-5xl">…</div>max-w-5xl (64rem) is the default: a comfortable measure for settings and
lists. Drop the max-width only for genuinely wide surfaces — an operator board,
a canvas. If you find yourself wanting a third width, you probably want a
different page.
PageHeader
<header class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<p class="text-muted-foreground text-xs font-medium tracking-wide uppercase">Project</p>
<h1 class="font-heading text-xl font-semibold tracking-tight text-balance">Acme Store</h1>
<p class="text-muted-foreground max-w-2xl text-xs/relaxed text-pretty">
One sentence on what this is and why it matters.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<!-- page-level actions -->
</div>
</header>- The eyebrow is the parent object's name — it tells the user where they are without a second breadcrumb trail.
- The description is one line on why the page matters, capped at
max-w-2xl. It is optional; an empty one is better than a restatement of the title. flex-wrapplusmin-w-0on the title block andshrink-0on the actions is what makes the header responsive without a breakpoint.
Put a status badge inline in the title rather than in a separate row:
<h1 class="…"><span class="flex flex-wrap items-center gap-2">Acme Store <span class="badge">live</span></span></h1>PageSection
<section class="flex flex-col gap-3">
<div class="flex flex-col gap-0.5">
<h2 class="font-heading text-sm font-medium">Queues</h2>
<p class="text-muted-foreground text-xs/relaxed">Optional one-liner.</p>
</div>
<!-- table, cards, form -->
</section>Sidebar
The app owns in-app navigation; the host owns cross-app navigation. Your sidebar never lists other products, the user's account, or a theme toggle — those live in the host navbar, one level up.
| Property | Value |
|---|---|
| Expanded width | 16rem |
| Collapsed (icon rail) | 3rem |
| Mobile drawer | 18rem |
| Collapse mode | icon rail, not off-canvas |
| Toggle shortcut | Cmd/Ctrl + B |
| Mobile breakpoint | 768px |
Rule. The sidebar grows with the URL — one group per entity depth, appearing when that entity is in scope. A flat list of every route in the product does not scale past the second nesting level, and it forces the user to hold the hierarchy in their head. Groups that appear as you descend make the hierarchy visible.
Workspace ← always
Projects
Project ← when a project is in scope
Overview
Experiences ▸
Settings ▸
Experience ← when an experience is in scope
Overview
Operate
…Swap the sidebar header by depth: the brand at the root, an entity switcher once you are inside one.
Active state matches the path exactly, or as a path prefix for parents:
const isActivePath = (pathname, href, exact) =>
exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);Below 768px the sidebar becomes a modal drawer. Keep the CSS breakpoint and
the JS media query aligned on the same value — a rail that is hidden by CSS
while the JS still thinks it is on desktop produces a toggle that does nothing.
Persist the collapsed state (a cookie works, and can be read during SSR to avoid a flash). If you write the state, read it back — a cookie nothing consumes is worse than no persistence, because it looks implemented.
Expose collapse state as a data attribute on a wrapper so children style themselves off it, rather than each child subscribing to state.
Responsiveness
Console apps run in an iframe whose width is the browser width minus nothing — but the same app may be open on a laptop beside a video call. Design for a range, not for phones.
Rule. Breakpoints are the last resort. In order of preference:
- Wrapping.
flex-wrapwithmin-w-0on the growing child andshrink-0on the fixed one. This handles most headers, toolbars and chip rows with no breakpoint at all. - Intrinsic grids.
grid-cols-[repeat(auto-fit,minmax(9rem,1fr))]fits as many tiles as the space allows and needs no maintenance when the tile count changes. - Container queries, when a component's layout depends on its own width rather than the viewport's — a card in a sidebar versus the same card in a main column.
- Media breakpoints, when the layout genuinely changes at a viewport size.
Keep the breakpoint vocabulary small: md: for the one-to-two column shift,
lg:/xl: for wide layouts. The reference app uses about a dozen breakpoint
utilities in total across the whole application.
Let content drive columns
A viewport-only rule gives you a half-width column for a single item on a wide screen. Key the column count off the data as well:
const cls = [
"grid items-start gap-4",
queues.length >= 2 && "md:grid-cols-2",
queues.length >= 3 && "xl:grid-cols-3",
].filter(Boolean).join(" ");One queue gets the full width at any size. Three get three columns when there is room for three.
Floors and gutters
body { min-width: 320px; }is the declared floor.- Ramp page padding:
p-4 md:p-6. A flatp-6leaves 24px gutters on a 360px screen, which costs 13% of the usable width.
Tables on small screens
Horizontal scroll is the baseline, and it is not sufficient on its own.
<div class="relative w-full overflow-x-auto" tabindex="0" role="region" aria-label="Projects">
<table class="w-full caption-bottom text-xs">…</table>
</div>Rule. A scrollable region must be reachable by keyboard. A tabindex="0"
plus an accessible name is required whenever the content can overflow — without
it, keyboard users cannot scroll the region at all (WCAG 2.1 scrollable region
focusable).
Rule. Past roughly four columns, provide a stacked fallback rather than a
700px-wide swipe strip. Cells are whitespace-nowrap by design — that keeps
rows scannable, but it also means a six-column table cannot compress. Below the
breakpoint, render each row as a labelled block:
<!-- ≥ md: table. < md: one block per record. -->
<dl class="flex flex-col gap-0.5 p-3 md:hidden">
<div class="flex justify-between gap-2"><dt class="text-muted-foreground">Mode</dt><dd>Walk-in</dd></div>
…
</dl>Keep an affordance that the region scrolls — a fading edge or a visible scrollbar. A cut-off column with no visual hint reads as missing data.