COMPONENT
Tabs
One panel at a time, addressable by URL, and no JavaScript anywhere. A tab is a link to a panel and the panel shown is the one the address bar names — so a tab can be deep-linked, reloaded, sent to somebody, and opened in a new browser tab, none of which anybody had to build.
Click one, and watch the address bar
The URL is the state. Reload the page and the tab you opened is still open.
Everything a package manager already knows how to do.
curl -fsSL https://example.dev/install.sh | sh
A tap and a formula, and it stays up to date with everything else.
brew install example
Winget ships with Windows, so there is nothing to install first.
winget install Example.Example
<div class="tabs icon-on-sunk">
<div class="tab-item" id="tab-linux">
<a class="tab" href="#tab-linux">
<svg class="icon icon-duo" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4.5" width="18" height="15" rx="2"></rect>
<path d="M7.5 10 L10.5 12.5 L7.5 15"></path>
<path d="M13 15.5 H16.5"></path>
</svg>
Linux
</a>
<div class="tab-panel flow flow-tight">
<p>Everything a package manager already knows how to do.</p>
<pre class="code-block"><code>curl -fsSL https://example.dev/install.sh | sh</code></pre>
</div>
</div>
<div class="tab-item" id="tab-macos">
<a class="tab" href="#tab-macos">
<svg class="icon icon-duo" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 9h6v6H9z"></path>
<path d="M9 9V7a2 2 0 1 0-2 2h2Zm0 6v2a2 2 0 1 1-2-2h2Zm6-6V7a2 2 0 1 1 2 2h-2Zm0 6v2a2 2 0 1 0 2-2h-2Z"></path>
</svg>
macOS
</a>
<div class="tab-panel flow flow-tight">
<p>A tap and a formula, and it stays up to date with everything else.</p>
<pre class="code-block"><code>brew install example</code></pre>
</div>
</div>
<div class="tab-item" id="tab-windows">
<a class="tab" href="#tab-windows">
<svg class="icon icon-duo" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="7" height="7"></rect>
<rect x="13" y="4" width="7" height="7"></rect>
<rect x="4" y="13" width="7" height="7"></rect>
<rect x="13" y="13" width="7" height="7"></rect>
</svg>
Windows
</a>
<div class="tab-panel flow flow-tight">
<p>Winget ships with Windows, so there is nothing to install first.</p>
<pre class="code-block"><code>winget install Example.Example</code></pre>
</div>
</div>
</div>tabs-fill
Equal shares of the row, for two or three tabs that are the whole choice.
<div class="tabs tabs-fill">
<div class="tab-item" id="plan-monthly">
<a class="tab" href="#plan-monthly">MONTHLY</a>
<div class="tab-panel">
<div class="rule-stack">
<div class="rule-row rule-row-spec">
<span class="rule-row-key">TEAM</span>
<span class="rule-row-value">24 a month</span>
</div>
<div class="rule-row rule-row-spec">
<span class="rule-row-key">SOLO</span>
<span class="rule-row-value">9 a month</span>
</div>
</div>
</div>
</div>
<div class="tab-item" id="plan-yearly">
<a class="tab" href="#plan-yearly">YEARLY</a>
<div class="tab-panel">
<div class="flow flow-tight">
<div class="rule-stack">
<div class="rule-row rule-row-spec">
<span class="rule-row-key">TEAM</span>
<span class="rule-row-value">240 a year</span>
</div>
<div class="rule-row rule-row-spec">
<span class="rule-row-key">SOLO</span>
<span class="rule-row-value">90 a year</span>
</div>
</div>
<p>Two months off, and the same terms.</p>
</div>
</div>
</div>
</div>tabs-boxed
Closes the panel. Reach for it when the panel holds enough that a reader has to be told where it stops — and skip it when one line of prose inside a page full of borders needs no box of its own.
Unpack the archive and run it.
tar xzf tool_x86_64.tar.gz
chmod +x tool
./tool
If you get Permission denied, the chmod is the fix.
Unpack it, then move it wherever you keep your tools.
tar xzf tool_arm64.tar.gz
mv tool /usr/local/bin
The first run asks Gatekeeper for permission. That is expected.
Unzip it and run the executable. There is nothing to install.
Expand-Archive tool_x64.zip
.\tool.exe
SmartScreen will ask once, because the build is not signed.
<!-- The base panel has a top and no end: the rule under the row says where it
starts and nothing says where it stops. This closes the box. -->
<div class="tabs tabs-boxed tabs-fill">
<div class="tab-item" id="unpack-linux">
<a class="tab" href="#unpack-linux">LINUX</a>
<div class="tab-panel flow flow-tight">
<p>Unpack the archive and run it.</p>
<pre class="code-block"><code>tar xzf tool_x86_64.tar.gz
chmod +x tool
./tool</code></pre>
<p>If you get Permission denied, the chmod is the fix.</p>
</div>
</div>
<div class="tab-item" id="unpack-mac">
<a class="tab" href="#unpack-mac">MACOS</a>
<div class="tab-panel flow flow-tight">
<p>Unpack it, then move it wherever you keep your tools.</p>
<pre class="code-block"><code>tar xzf tool_arm64.tar.gz
mv tool /usr/local/bin</code></pre>
<p>The first run asks Gatekeeper for permission. That is expected.</p>
</div>
</div>
<div class="tab-item" id="unpack-windows">
<a class="tab" href="#unpack-windows">WINDOWS</a>
<div class="tab-panel flow flow-tight">
<p>Unzip it and run the executable. There is nothing to install.</p>
<pre class="code-block"><code>Expand-Archive tool_x64.zip
.\tool.exe</code></pre>
<p>SmartScreen will ask once, because the build is not signed.</p>
</div>
</div>
</div>tabs-grouped
The row encloses itself and reads as a button group. It borrows segmented's look rather than a second set of values, and it is wired to the relief dial — closed tabs sit at control, the open one at well.
Nine hours, one place, and everything that came out of it is yours.
Turn the relief dial up on the theme builder and this row becomes a real button group: the closed tabs lift, the open one presses in.
Five of those, with the evenings to think between them.
At the shipped relief of zero it is flat hairlines, exactly as everything else here is.
Long enough to change your mind once and still ship.
The open one is marked by luminance and full-strength ink rather than by a hue, which is the same rule the segmented control follows.
<!-- The row closes itself. Composed with `tabs-boxed` here, which is the pair
you want when the tabs ARE the interface: a group of buttons, and the panel
they open, both saying where they stop.
The two-tone icons fill their interiors with whatever the tab is sitting on,
which the group states for them — a tab changes surface with its state and
no class on the caller can be both. -->
<div class="tabs tabs-grouped tabs-boxed">
<div class="tab-item" id="grouped-day">
<a class="tab" href="#grouped-day">
<svg class="icon icon-duo" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="8"></circle>
<path d="M12 8v4l2.5 1.5"></path>
</svg>
DAY
</a>
<div class="tab-panel flow flow-tight">
<p>Nine hours, one place, and everything that came out of it is yours.</p>
<p>Turn the relief dial up on the theme builder and this row becomes a real button group: the closed tabs lift, the open one presses in.</p>
</div>
</div>
<div class="tab-item" id="grouped-week">
<a class="tab" href="#grouped-week">
<svg class="icon icon-duo" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="15" rx="2"></rect>
<path d="M3.5 9.5h17M8 3v4M16 3v4"></path>
</svg>
WEEK
</a>
<div class="tab-panel flow flow-tight">
<p>Five of those, with the evenings to think between them.</p>
<p>At the shipped relief of zero it is flat hairlines, exactly as everything else here is.</p>
</div>
</div>
<div class="tab-item" id="grouped-month">
<a class="tab" href="#grouped-month">
<svg class="icon icon-duo" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="15" rx="2"></rect>
<path d="M7.5 12.5h3v3h-3zM13.5 12.5h3v3h-3z"></path>
</svg>
MONTH
</a>
<div class="tab-panel flow flow-tight">
<p>Long enough to change your mind once and still ship.</p>
<p>The open one is marked by luminance and full-strength ink rather than by a hue, which is the same rule the segmented control follows.</p>
</div>
</div>
</div>tabs-lede, and tabs-bare under it
Words at the start of the row and the tabs at the far end of it. tabs-lede is a slot: it takes the slack, so the control lands against the other edge. It caps its own measure at --ds-measure-wide and keeps --ds-gap-wide between itself and the control — in whichever axis they end up sharing, so the two never touch when the row wraps. Both are knobs. tabs-bare is the panel with no rule above it, for a panel that is already an object with edges of its own.
<!-- Words on the left of the row, the tabs at the far end of it, and a whole
window under them. `tabs-lede` is the slot; `tabs-bare` is what stops the
panel drawing a hairline a few pixels above the window's own edge.
`--tab-lede-flip` is the width at which the words stop sharing the line and
take one of their own. The default guesses from the measure, which suits a
sentence; this row is one short line beside three wide tabs, so it says so.
Below the flip every tab moves down together — the row is never half of
each. -->
<div class="tabs tabs-grouped tabs-bare" style="--tab-lede-flip: 620px" x-ds-tabs>
<div class="tabs-lede flow flow-tight">
<span class="section-head-kicker">THE WORKSPACE IS YOURS</span>
<!-- A plain paragraph. The slot caps its own measure at
`--ds-measure-wide`, so nothing here has to ask for one — and NOT
`section-head-lede`, which carries `flex: 0 1 var(--ds-head-note)`:
a WIDTH inside the row it belongs to, and a 400px HEIGHT the moment it
is dropped into a column. See section-head.css's traps. -->
<p>Every panel docks, floats or closes.</p>
</div>
<div class="tab-item" id="ws-full">
<a class="tab" href="#ws-full">Everything</a>
<div class="tab-panel">
<div class="chrome">
<div class="chrome-bar">
<span class="chrome-lights"></span>
<span class="chrome-title">Cloud9.flatty</span>
<span class="chrome-meta">16 x 16 · 4 LAYERS · 892 TRIS</span>
</div>
<div class="placeholder" style="--ratio: 16/9">
<span class="placeholder-caption">EVERY PANEL OPEN</span>
</div>
<p class="chrome-foot">Everything open at once: canvas, model, outliner, palette and vector pane.</p>
</div>
</div>
</div>
<div class="tab-item" id="ws-split">
<a class="tab" href="#ws-split">Split</a>
<div class="tab-panel">
<div class="chrome">
<div class="chrome-bar">
<span class="chrome-lights"></span>
<span class="chrome-title">Cloud9.flatty - split</span>
<span class="chrome-meta">CANVAS + 3D VIEW</span>
</div>
<div class="placeholder" style="--ratio: 16/9">
<span class="placeholder-caption">TWO PANES, SIDE BY SIDE</span>
</div>
<p class="chrome-foot">Canvas and model side by side. The arrangement most people draw in.</p>
</div>
</div>
</div>
<div class="tab-item" id="ws-focused">
<a class="tab" href="#ws-focused">Focused</a>
<div class="tab-panel">
<div class="chrome">
<div class="chrome-bar">
<span class="chrome-lights"></span>
<span class="chrome-title">Cloud9.flatty - focused</span>
<span class="chrome-meta">CANVAS FIRST</span>
</div>
<div class="placeholder" style="--ratio: 16/9">
<span class="placeholder-caption">ONE PANE, THE REST PUSHED ASIDE</span>
</div>
<p class="chrome-foot">Panels pushed aside when you just want to paint pixels.</p>
</div>
</div>
</div>
</div>tabs-end, when there is nothing to say on that line
The row in the corner above what it switches. tabs-lede already lands the tabs there, by putting words at the near end and letting them take the slack — this is the same place reached the other way, for when you have no words to put. Which one you want is decided by whether that line has something to say on it, not by where the control should sit. The panel is untouched either way: it has a line of its own and no slack to be pushed along.
<!-- The control in the corner, with nothing beside it. `tabs-end` puts the row
at the far end of its line; `tabs-lede` gets there the other way, by
putting words at the near end and letting them take the slack. -->
<div class="tabs tabs-grouped tabs-bare tabs-end" x-ds-tabs>
<div class="tab-item" id="view-everything">
<a class="tab" href="#view-everything">Everything</a>
<div class="tab-panel">
<div class="chrome">
<div class="chrome-bar">
<span class="chrome-lights"></span>
<span class="chrome-title">study.canvas</span>
<span class="chrome-meta">5 PANELS</span>
</div>
<div class="placeholder" style="--ratio: 16/9">
<span class="placeholder-caption">EVERY PANEL OPEN</span>
</div>
</div>
</div>
</div>
<div class="tab-item" id="view-split">
<a class="tab" href="#view-split">Split</a>
<div class="tab-panel">
<div class="chrome">
<div class="chrome-bar">
<span class="chrome-lights"></span>
<span class="chrome-title">study.canvas</span>
<span class="chrome-meta">2 PANELS</span>
</div>
<div class="placeholder" style="--ratio: 16/9">
<span class="placeholder-caption">TWO PANES, SIDE BY SIDE</span>
</div>
</div>
</div>
</div>
<div class="tab-item" id="view-focused">
<a class="tab" href="#view-focused">Focused</a>
<div class="tab-panel">
<div class="chrome">
<div class="chrome-bar">
<span class="chrome-lights"></span>
<span class="chrome-title">study.canvas</span>
<span class="chrome-meta">1 PANEL</span>
</div>
<div class="placeholder" style="--ratio: 16/9">
<span class="placeholder-caption">ONE PANE, THE REST PUSHED ASIDE</span>
</div>
</div>
</div>
</div>
</div>Reference
Read from components/tabs.css when this page was built, so it cannot fall behind the stylesheet.
.tabs.tab-item.tabs-lede.tab.tab-panel.tabs-fill.tabs-boxed.tabs-grouped.tabs-bare.tabs-end--tab-lede-measure — falls back to --ds-measure-wide--tab-lede-gap — falls back to --ds-gap-wide--tab-pad — falls back to --ds-pad-tab--tab-mark — falls back to --ds-shadow-flat--tab-motion — falls back to --ds-motion-panel--ds-accent--ds-flow-tight--ds-focus-ring-inset--ds-focus-width--ds-font-display--ds-gap-inline--ds-hairline--ds-hit-min--ds-ink--ds-ink-soft--ds-motion-fast--ds-pad-card--ds-radius--ds-radius-control--ds-raised--ds-rule--ds-shadow-control--ds-shadow-well--ds-sunk--ds-tab-mark--ds-text-ui--ds-track-subtleNotes
The obvious thing to write is a section-head above the tabs, and it puts the control on a line of its own. A tab and its panel are siblings in one flex container — that is what display: contents on the item buys — so anything that wants to share their line has to be a flex item too. Hence a slot, and hence the one tabs-* here that is a part rather than a modifier.
It also decides the row's alignment, and only when it is there. Tabs on their own are all the same height, so stretching them shows nothing; put two lines of lede beside them and stretching pulls every tab to the lede's full height. A :has() says so once, instead of asking you for a second class you would have to remember.
Side by side it is the space between the sentence and the control; stacked it is the space above the control. Both read --tab-lede-gap, so they cannot drift. Neither is a gap on the group, and that is not fussiness: a column gap would separate the tabs from each other, and a row gap lands between two wrapped tab rows as well — pulling a grouped control into two pills — and again above the panel, on top of the padding it already draws.
The block half sits on the first tab rather than under the lede. align-items: flex-end aligns margin boxes, so a bottom margin on the lede would lift the sentence off the control's foot — breaking the alignment it is standing next to. A top margin on the tab is inert while they share a line and becomes the gap the moment the row wraps.
Optional, and the tabs are unchanged without it. What it fixes is not a layout jolt — traced frame by frame, the group's height never moves. It is the instant between the two panels: the outgoing one is gone at the next frame and the arriving one opens at zero opacity, so a panel full of picture blinks out and fades back. On the theme builder that read as a page reload.
There is no declarative way to do it, which is the test this library applies before writing a line of script. @view-transition is cross-document only and nothing fires one on a :target change; and the CSS answer — stacking the panels in one cell and fading between them — is not open to a flex row, which cannot put two items in the same place.
With no script the tabs still work, because they are links to a fragment. Declined motion skips the transition and keeps the switch — a view transition is started by script, so the media query in base.css cannot reach it and the directive has to ask.
Narrow the window and watch: the tabs go down as a group. They used to go one at a time, which left a tab or two marooned beside the sentence and the rest stretched across a second line — swept at forty-pixel steps, that happened across a 160px band and it looked like a mistake.
There is no media query behind it and no container query either. The slot's flex-basis is a calculation that goes hugely positive below --tab-lede-flip — so the words take a whole line and every tab follows them down — and clamps to zero above it, where flex-grow fills the slack instead. Two states, one declaration.
The default flip is derived: the words at their measure, as much again for a control, and the gap between. That is the right guess for a sentence and too cautious for a phrase, so it is a knob — the specimen above sets its own, and says why.
The slot grows — that is what puts the control against the far edge — so the cap goes on the words inside it rather than on the box. Without one a sentence runs the whole half-row: 716px of one line of body copy beside a control, measured, which is the arrangement this exists to avoid.
It is a knob rather than a composed measure because a consuming site cannot write the composed one: measure takes its cap from an inline --measure, and a site's own checks refuse inline style attributes outright. A recipe only this book can follow is not a recipe.
tabs-bare draws nothing, the base draws a rule, and tabs-boxed draws a whole box. Which one you want is a question about the panel's content rather than about the tabs: a paragraph needs telling where it starts, and a window does not — its own top edge would sit a few pixels under the rule and you would have two lines where the design has one.
Bare and grouped together round all four corners of the row. Grouped on its own squares the bottom two on purpose, because the panel's rule runs directly beneath them and a rounded foot lifts off that line. Take the rule away and the argument goes with it.
Most CSS-only tabs hide a radio input behind a label. That works, and it buys you nothing beyond the click: the state lives in the DOM, so it does not survive a reload, cannot be linked to, and means nothing to the back button.
Here the state is the address bar. A tab is an <a href="#panel-id"> and the panel shown is the one :target matches. Send somebody a link to the Linux instructions and they land on the Linux instructions.
<div class="tabs">
<div class="tab-item" id="install-linux">
<a class="tab" href="#install-linux">Linux</a>
<div class="tab-panel">…anything at all…</div>
</div>
<div class="tab-item" id="install-mac">
<a class="tab" href="#install-mac">macOS</a>
<div class="tab-panel">…</div>
</div>
</div>That one line is what makes this usable rather than clever. A :target link normally scrolls its target to the top of the viewport — which is exactly why CSS-only tabs halfway down a page are unbearable. .tab-item is display: contents, so it generates no box at all: getClientRects() is empty, the browser has nothing to scroll to, and it does not scroll. Measured with the group centred in the viewport: 0px.
It also keeps the CSS generic. Targeting a wrapper reaches the tab and its panel through one selector, so there is no per-id rule and no counting of :nth-child — which is what a link and a panel that are merely siblings would force, and what breaks the day you add a fourth tab.
The three above each carry one. There is nothing special about that — a tab is an ordinary link and whatever you put inside it is the title, which is the advantage of not hiding a radio input behind an aria-label. Drop an icon in, mark it aria-hidden because the word beside it already says what it is, and the gap is the component's.
Equal shares of the row, for two or three tabs that are the whole choice. It reads as a control rather than as a row of links — right when the tabs are the interface, wrong when they are one of several things on the page.
/* One group, roomier and slower than the system default. */
.hg-install-tabs {
--tab-pad: 16px 24px;
--tab-motion: 400ms;
}A document has one :target, so one group per page can be off its first tab. Put two groups on a page and both work; open a tab in the second and the first falls back to its first tab. Follow a link to a heading on the same page and every group resets, for the same reason — the heading is now what the URL names.
That is the honest price of deep links, and for documentation it is a bargain. A page that genuinely needs two independent groups open at once wants something driven by a script, and this is not it.
There is also no tab-active class, and there will not be: a class saying which tab is open, on a component whose state is the URL, is a second answer that disagrees with the first the moment somebody clicks. The first tab is the one that opens — put the one you want first.
You will not find role="tab" here. That role is a promise about arrow keys, aria-selected and a roving tabindex, and CSS can keep none of it. A role whose behaviour is missing is worse for somebody using a screen reader than the plain truth — which is that this is a list of links to sections of the page. Tab moves between them and Enter opens one, because that is what a link does.
Transitioning display with allow-discrete — the way the modal does — keeps the outgoing panel in the layout while it fades, so both are displayed for the length of the transition. Measured: the group grew from 64px to 86px and back on every switch. Fading only the arrival keeps exactly one panel in flow at all times and still animates, at 0.80 opacity 90ms into a 240ms curve. Declined motion is handled once, globally, in base.css.