Layout
Application shell
The framework of a screen: a side column, a header, a work area. Every part scrolls by itself — the side navigation does not travel away while the table is being read.
Runs
Markup
<div class="inst-shell">
<aside class="inst-shell-aside">
<a class="inst-shell-brand" href="#"><span class="inst-dot" data-tone="ok"></span>instrument</a>
<nav class="inst-nav" aria-label="Sections">
<div class="inst-nav-group">
<span class="inst-nav-label">Work</span>
<a class="inst-nav-item" href="#" aria-current="page">Runs
<span class="inst-badge inst-nav-count">7</span></a>
<a class="inst-nav-item" href="#">Queue</a>
</div>
</nav>
</aside>
<header class="inst-shell-header">
<nav aria-label="Breadcrumbs">
<ol class="inst-crumbs">
<li><a href="#">Projects</a></li>
<li><span aria-current="page">Run #4127</span></li>
</ol>
</nav>
<span class="inst-cluster-spacer"></span>
<button class="inst-btn inst-btn--sm" type="button">Export</button>
<button class="inst-btn inst-btn--sm inst-btn--primary" type="button">Run</button>
</header>
<main class="inst-shell-main" id="main">
<div class="inst-container inst-container--full inst-stack">
<div class="inst-page-header">
<div class="inst-page-header-main">
<h1 class="inst-page-title">Runs</h1>
</div>
</div>
</div>
</main>
</div>
Contract
<a class="inst-skip-link" href="#main">To the content</a>
<div class="inst-shell">
<aside class="inst-shell-aside">
<a class="inst-shell-brand" href="/">The application</a>
…
</aside>
<header class="inst-shell-header">…</header>
<main class="inst-shell-main" id="main">…</main>
</div>
The order in the markup is aside, header, main; on the screen they are
placed by grid-template-areas, so the header stands to the right of the
column rather than above it. On a narrow screen the order of the areas is
changed by the same property — with no second set of markup.
The skip to the content stands before the shell: it is the first thing the
keyboard meets, and the href is obliged to point at the id of the work
area.
| What | Why |
|---|---|
An inst-skip-link first in the <body> |
Otherwise the keyboard walks the whole navigation all over again every time |
An id on inst-shell-main |
The target of the skip |
An <aside> / <header> / <main> |
The landmarks of the page. A <div> gives none |
An aria-label on every <nav> inside |
There are usually three of them in a shell: the sections, the crumbs, the tabs |
The width of the column is retuned in one line:
.my-app { --aside-w: 280px; }
Accessibility
| The skip to the content | The first element in the order of traversal. Hidden until focus by clip-path rather than by display: none: what is hidden that way stays focusable |
| Landmarks | An <aside>, a <header> and a <main> give navigation by landmarks with not a single role |
| Scrolling | Three independent areas with overflow: auto. overscroll-behavior is not set: on a container with nothing to scroll, contain eats the mouse wheel |
| Height | 100dvh rather than 100vh — on mobile the panel of the browser otherwise cuts off the bottom of the work area |
| Wrapping in the header | Required: without it a narrow screen gets horizontal scrolling of the document, and 100dvh stops fitting by height |
| The side column is hidden, the shell unfolds into the ordinary flow, the scrolling areas are printed entire |
Anatomy
| The area | What it does |
|---|---|
inst-shell-aside |
The side column, --aside-w wide. It scrolls by itself and lies on --surface-sunken |
inst-shell-header |
The header. A row with required wrapping: on a narrow screen the crumbs and the controls go to a second line rather than pushing the document past the viewport |
inst-shell-main |
The work area. It scrolls by itself, gives the vertical air and is declared the container app |
The horizontal air is given not by the work area but by the container inside it: their work differs — one bounds the measure of reading, the other holds the scrolling.
The heading of the column
The name of the application is the first child of the column, before the navigation.
The class lives in the library because of one line — the space below it. In a column it is required: without it the name stands in a row with the items and becomes the first of them. In a row (a narrow screen) that same line shifts the name up by half of itself, and the logo stops standing on one line with the items. A divergence of six pixels, which is seen at once and never found — because it is looked for in the alignment, and it lies in the spacing.
The sticky action bar
The work area declares its own bottom indent through the variable
--scroll-pad-end.
position: sticky fixes itself relative to the padding box of the
scrolling area rather than to its border. An action bar fixed at
inset-block-end: 0 stops the height of the indent above the visible bottom —
and a live slit is left under it, through which the content is seen travelling.
The bar stops being the floor.
Only the one who set that indent knows about it. Hence the variable:
the action bar subtracts it itself. A scrolling
area of your own — declare --scroll-pad-end on it.
Behavior
@media (width < 60rem) { … }
The only threshold in the whole library. The shell is the one thing that
really does depend on the size of the window: everything else rebuilds by the
intrinsic or answers to the width of its own area through @container — see
the design principles.
The work area is declared a @container region under the name app: its width
arrives from the grid of the shell rather than from the contents, so declaring
it a container is safe.
/* A rule of your own under a narrow work area */
@container app (inline-size < 50rem) {
.my-board { grid-template-columns: 1fr; }
}
Below 60rem there are two behaviours, and the choice between them is by the length of the navigation.
The default: a horizontal strip
The column becomes a ribbon above the content:
| What changes | How |
|---|---|
| The order of the areas | header, aside, main — as a column |
| The column | A row with horizontal scrolling, a bottom line instead of a side one |
inst-nav, inst-nav-group |
They unfold into a row |
inst-nav-label |
Hidden: the labels of groups do not fit into a strip |
inst-nav-item |
It stops wrapping its label |
It works with not a single line of JS and therefore cannot "fail to open": an application that has connected nothing still has navigation on a phone. It is fit for roughly up to eight items.
The variant: a sliding drawer
<aside class="inst-shell-aside inst-shell-aside--drawer">…</aside>
<button class="inst-btn inst-btn--icon inst-shell-nav-toggle" type="button"
aria-label="Sections" aria-expanded="false" aria-controls="sidebar">…</button>
As soon as there are more than eight items, the ribbon stops working: seventy-one items is a ribbon almost eight thousand pixels wide with no headings of groups. That is no longer "inconvenient", that is an absence of navigation.
The drawer keeps the vertical and the labels of the groups, slides out from
behind the start edge and dims the background with the same --scrim backing
as a modal.
| The class | The work |
|---|---|
inst-shell-aside--drawer |
The column goes into a fixed layer and slides out on demand |
inst-shell-nav-toggle |
The button that calls it. On a wide screen it is hidden: the column is in place as it is, and the button would be a deceit |
What the application gives. One line — to toggle the aria-expanded on the
button. Exactly the same contract as a modal
with showModal(). The focus trap, the Escape and the click on the backing
are the layer of the application too: the library draws the state but does not
work it out.
Patterns
A shell by itself is three empty areas. The work is seen when something stands in them, and the main thing in it is not seen in a picture at all: every area scrolls by itself. Scroll the table — the column and the header will stay in place.
A screen assembled on this shell lies in "Assemblies": the console of runs. There it is also seen how the navigation, the page header, the split and the states — empty, loading, refusal — are seated into it.
API
| Name | What it does |
|---|---|
| class | |
inst-shell | A grid the full height of the window (100dvh) |
inst-shell-aside | The side column |
inst-shell-brand | The name of the application above the sections. The first child of the column |
inst-shell-header | The header |
inst-shell-main | The work area. The app container |
inst-skip-link | A skip to the content. Visible by :focus-visible alone |
| Name | Value | Where from |
|---|---|---|
| token | ||
--scroll-pad-endThe bottom indent of a scrolling area. Declared by the shell, subtracted by a sticky action bar | var(--pad-panel) | one across every cell |
--aside-w | 232px | base +4: scale |
--pad-panel | var(--space-5) | base +14: density · scale |
--space-3 | 6px | one across every cell |
--space-4 | 8px | one across every cell |
--space-5 | 12px | one across every cell |
--gap-inline | var(--space-4) | base +14: density · scale |
--hairline | 1px | base +1: screen |
--border | light-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11)) | one across every cell |
--surface-sunken | light-dark(var(--n-2), var(--n-14)) | base +1: theme |
--surface-page | light-dark(var(--n-1), var(--n-13)) | base +1: theme |
--surface-raised | light-dark(var(--n-0), var(--n-12)) | base +1: theme |
--radius-md | 8px | base +4: scale |
--text-sm | 0.875rem | base +4: scale |
--text-lg | 1.125rem | base +4: scale |
--control-h-md | 32px | base +14: density · scale |
--tracking-tight | -0.01em | one across every cell |
--text-primary | light-dark(var(--n-12), var(--n-1)) | one across every cell |
--z-modal | 1000 | one across every cell |
Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument