Shell
tier 1 — the frameThe page shells: the full three-part layout, the two reduced ones, and the rhythm inside them. Each part stands on its own, so an app combines the ones it needs; these are combinations to start from.
<AppShell> and there will not be one — copy the markup below, and report a
frame problem against the library rather than overriding it locally.
Full shell
.layout is the flex row that fills the viewport — shown here at a fixed
height — .content is the column beside the sidebar, and .page is
the only scroll container. Width is two knobs on .sidebar,
--sidebar-width (220px) and --sidebar-width-collapsed (56px);
nothing else about the frame changes with them.
<div class="layout" style="height:460px; overflow:hidden">
<aside class="sidebar">
<div class="brand">
<div class="brand-logo" style="background:var(--brand)"></div>
<div class="brand-text">
<strong>Dispatch Console</strong>
<span class="brand-sub">Northwind Retail</span>
</div>
</div>
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link active" href="#"><i class="ri-inbox-line"></i><span>Queue</span><span class="nav-count">3</span></a>
<a class="nav-link" href="#"><i class="ri-chat-3-line"></i><span>Orders</span></a>
<a class="nav-link" href="#"><i class="ri-check-double-line"></i><span>Decided</span></a>
<div class="nav-section">
<span class="nav-section-label">Administration</span>
<a class="nav-link" href="#"><i class="ri-robot-line"></i><span>Automation</span></a>
<a class="nav-link" href="#"><i class="ri-settings-3-line"></i><span>Settings</span></a>
</div>
<a class="nav-status-card nav-status-card--ok" href="#">
<span class="nav-status-label">Workflow API</span>
<span class="nav-status-row">
<span class="nav-status-dot"></span>
<span class="nav-status-state">Connected</span>
</span>
</a>
<a class="nav-status-card nav-status-card--fail" href="#">
<span class="nav-status-label">MySQL</span>
<span class="nav-status-row">
<span class="nav-status-dot"></span>
<span class="nav-status-state">Unreachable</span>
</span>
</a>
</div>
<div class="nav-tools">
<a class="nav-link nav-link-tool" href="#"><i class="ri-book-2-line"></i><span>Documentation</span><i class="ri-external-link-line nav-link-ext"></i></a>
</div>
</nav>
</aside>
<div class="content">
<header class="topbar">
<button class="topbar-btn topbar-btn--start" aria-label="Collapse sidebar" data-tip="Collapse the sidebar to an icon rail."><i class="ri-side-bar-line"></i></button>
<div class="topbar-spacer"></div>
<div class="topbar-section">
<span class="topbar-version">v4.2.1</span>
<span class="health-badge health-badge--live"><span class="health-dot"></span> <span>Live</span></span>
</div>
<button class="topbar-btn" aria-label="Help" data-tip="What is this console?"><i class="ri-question-line"></i></button>
<div class="user-widget">
<button class="user-trigger">
<span class="user-avatar"><i class="ri-user-line"></i></span>
<span class="user-info">
<span class="user-name">Alex Fischer</span>
<span class="user-email">alex.fischer@example.com</span>
</span>
</button>
<a class="user-signout" href="#" aria-label="Sign out" data-tip="Sign out of the console."><i class="ri-logout-box-r-line"></i></a>
</div>
</header>
<div class="page">
<a class="back-link" href="#"><i class="ri-arrow-left-line"></i> <span>Back to the queue</span></a>
<h1>Approval queue</h1>
<p class="lede">Items the AI proposed and is waiting on a human decision for.</p>
<div class="card">
<div class="card-head">
<strong>ORD-4209</strong>
<span class="badge badge--warn">Pending</span>
</div>
<div class="card-body">
<p class="form-hint">Reservation: reroute to EU-West (confidence 0.87).</p>
</div>
</div>
</div>
</div>
</div>
Bare shell
.bare-layout — topbar and body, no sidebar, for the page that comes before
there is a workspace to navigate: choosing one, accepting an invitation, a first-run step.
Where there is nothing to offer at all use .auth-layout below;
Common pages has the finished pages.
<div class="bare-layout" style="height:280px">
<header class="topbar">
<div class="bare-brand">
<div class="brand-logo" style="background:var(--brand)"></div>
<span>Acme Ops</span>
</div>
</header>
<div class="page">
<div class="card" style="max-width:440px">
<div class="card-head"><strong>Choose a workspace</strong></div>
<div class="card-body sedna-col">
<p class="form-hint">Your account is a member of two.</p>
<a class="btn" href="#"><i class="ri-building-line"></i> <span>Northwind Retail</span></a>
<a class="btn" href="#"><i class="ri-building-line"></i> <span>Contoso Logistics</span></a>
</div>
</div>
</div>
</div>
Auth layout
Sign-in, sign-out, “check your e-mail”, an invitation — one centred card and no
navigation. .auth-card is capped rather than stretched.
Use your organisation account.
Trouble signing in? Ask the service desk.
<div class="auth-layout" style="min-height:0; padding: var(--space-9)">
<div class="auth-brand">
<span class="brand-logo" style="width:32px; height:32px; background:var(--brand)"></span>
<span>Dispatch Console</span>
</div>
<div class="card auth-card">
<div class="card-head"><strong>Sign in</strong></div>
<div class="card-body sedna-col">
<p class="form-hint">Use your organisation account.</p>
<button class="btn btn--brand btn--solid"><i class="ri-microsoft-fill"></i> <span>Continue with Microsoft</span></button>
</div>
</div>
<p class="auth-foot">Trouble signing in? Ask the service desk.</p>
</div>
Full-bleed layout
For content that owns the whole area below the header — a map, a board, a diagram editor.
Use it instead of .page and never nested inside it, and put
.full-layout-fill on the child that takes the height that is left.
<div class="layout">
<aside class="sidebar">…</aside>
<div class="content">
<header class="topbar">…</header>
<div class="full-layout">
<div class="toolbar">…</div>
<div class="full-layout-fill">The map fills whatever is left.</div>
</div>
</div>
</div>
The rhythm between page sections
Every direct child of .page after the first gets --page-gap above
it, so a card after a card needs no class and no style="margin-top". Set
--page-gap: 0 on .page to manage the rhythm yourself, or
.sedna-mt-0 on the one child that should sit flush; inside
.card-body there is no equivalent — reach for
.sedna-col with a gap step.
Reconciliation
Two cards and an alert, with nothing spacing them by hand.
Last run
Next run
<div class="page" style="padding: var(--space-7); border: 1px dashed var(--border)">
<div class="page-head">
<div class="page-head-text">
<h2>Reconciliation</h2>
<p>Two cards and an alert, with nothing spacing them by hand.</p>
</div>
</div>
<div class="alert alert--info">
<i class="ri-information-line"></i>
<span>Seven accounts differ in cost centre and are held for approval.</span>
</div>
<div class="card">
<div class="card-head"><h3>Last run</h3></div>
<div class="card-body">1 204 of 1 211 accounts matched.</div>
</div>
<div class="card">
<div class="card-head"><h3>Next run</h3></div>
<div class="card-body">Tonight at 02:00 CET.</div>
</div>
</div>
A footer at the bottom of a short page
.page--fill makes the page a flex column, so a .page-foot —
a footer, a build line — sits at the bottom of a page that is shorter than the
viewport rather than under its last paragraph, and a lone
.empty-state--page centres itself between the top
and the foot. Opt in per page: a flex column stops sibling margins collapsing, which the
block page relies on. This site's own pages use it.
Exports
Nothing has been exported this month.
<div class="page page--fill" style="height:300px; border:1px dashed var(--border-strong); border-radius:var(--radius-surface)">
<div class="page-head">
<div>
<h1>Exports</h1>
<p class="lede">Nothing has been exported this month.</p>
</div>
</div>
<div class="card">
<div class="card-body">Two lines of content, and then the page ends.</div>
</div>
<footer class="page-foot text-muted text-sm">
Northwind Retail · build 8841 · <a href="#">Status</a>
</footer>
</div>
Narrow screens
Add .layout--responsive to .layout and the sidebar becomes the
rail below 900px, where it is forced — so hide a collapse toggle at that width or accept
the no-op. .layout--compact-at-sm moves that width to 560px and
.layout--compact-at-lg to 1200px. A topbar outside a .layout opts in with
.topbar--responsive on the .topbar instead. Put
.sidebar-wide on your own content in the sidebar that has no place in the
rail — it goes whenever the labels do, toggled or forced — and .topbar-wide on
your own header content that can go below 560px.
<!-- The rail below 900px; add layout--compact-at-sm for 560px or layout--compact-at-lg for 1200px. -->
<div class="layout layout--responsive">
<aside class="sidebar">
…
<div class="nav-tools">
<p class="sidebar-wide text-muted text-sm">Synced 2 minutes ago</p>
</div>
</aside>
<div class="content">
<header class="topbar">
…
<span class="topbar-wide text-muted text-sm">Read-only replica</span>
…
</header>
…
</div>
</div>
On a phone, the document scrolls
.page is the frame's only scroll container, and that is the one thing a
phone's browser cannot see: Safari collapses its toolbar only while the document
scrolls, so a page scrolling inside a box keeps the full toolbar on screen for ever.
.layout--flow hands the scrolling to the document below 560px — the
shell grows with its content, the topbar sticks under the notch, a sidebar sticks to the
top of its column and a bottom bar to the bottom of the viewport — and changes
nothing above it. This site uses it. Keep the box for a page that must never move under
its header, a full-bleed map or an editor. Safari on iOS 26 can leave its viewport offset
after the page has scrolled to its end or the keyboard has closed, which draws the bottom
bar and the header in the middle of the page; Sedna.UI.js notices and nudges
it back, once, and only then.
<div class="layout layout--flow">
<aside class="sidebar">…</aside>
<div class="content">
<header class="topbar">…</header>
<div class="page">…</div>
</div>
</div>
A wall screen
.kiosk is a screen nobody operates: the whole viewport, never scrolled, no
cursor, and the same canvas about 1280px wide scaled up to every screen — 1.5 at 1080p, 2 at
1440p, 3 at 4K — so a page designed once reads the same on each; --kiosk-zoom
sets a factor of your own. No scrollbar is drawn inside it, because what moves is moved by
the app. .kiosk-strip, .kiosk-stage and .kiosk-side
keep their areas when one is empty; --kiosk-side is the side column's width.
<!-- The whole page of a wall screen: no sidebar, no header, nothing to operate. -->
<body>
<div class="kiosk">
<header class="kiosk-strip">…the newest runs, in a row…</header>
<main class="kiosk-stage">…the run in progress…</main>
<aside class="kiosk-side">
<ul class="list" data-follow>…the queue, which the app scrolls…</ul>
</aside>
</div>
</body>
Skip link
The first focusable thing on the page, jumping past the navigation to the content —
off-screen rather than hidden, so it stays focusable. tabindex="-1" on
<main> is not optional: without it the jump only scrolls and focus stays
back in the navigation. Neither is Sedna.UI.js: under the host page's
<base href="/"> a bare #main means the start page, and the
script is what keeps the jump here. This site has one — press Tab on any page.
<body>
<a class="skip-link" href="#main">Skip to content</a>
<div class="layout">
<aside class="sidebar">…</aside>
<div class="content">
<header class="topbar">…</header>
<div class="page">
<main id="main" tabindex="-1">…</main>
</div>
</div>
</div>
</body>