Skip to content

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.

Linux

Everything a package manager already knows how to do.

curl -fsSL https://example.dev/install.sh | sh
macOS

A tap and a formula, and it stays up to date with everything else.

brew install example
Windows

Winget ships with Windows, so there is nothing to install first.

winget install Example.Example
HTML
<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.

MONTHLY
TEAM 24 a month
SOLO 9 a month
YEARLY
TEAM 240 a year
SOLO 90 a year

Two months off, and the same terms.

HTML
<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.

LINUX

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.

MACOS

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.

WINDOWS

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.

HTML
<!-- 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.

DAY

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.

WEEK

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.

MONTH

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.

HTML
<!-- 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.

THE WORKSPACE IS YOURS

Every panel docks, floats or closes.

Everything
Cloud9.flatty 16 x 16 · 4 LAYERS · 892 TRIS
EVERY PANEL OPEN

Everything open at once: canvas, model, outliner, palette and vector pane.

Split
Cloud9.flatty - split CANVAS + 3D VIEW
TWO PANES, SIDE BY SIDE

Canvas and model side by side. The arrangement most people draw in.

Focused
Cloud9.flatty - focused CANVAS FIRST
ONE PANE, THE REST PUSHED ASIDE

Panels pushed aside when you just want to paint pixels.

HTML
<!-- 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.

Everything
study.canvas 5 PANELS
EVERY PANEL OPEN
Split
study.canvas 2 PANELS
TWO PANES, SIDE BY SIDE
Focused
study.canvas 1 PANEL
ONE PANE, THE REST PUSHED ASIDE
HTML
<!-- 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.

CLASSES.tabs.tab-item.tabs-lede.tab.tab-panel.tabs-fill.tabs-boxed.tabs-grouped.tabs-bare.tabs-end
SETTINGSSet the first on one element; a theme sets the second for all of them.--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
READS--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-subtle

Notes

THE LEDE HAS TO BE INSIDE THE GROUP

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.

THE GAP IS ONE DECISION IN TWO AXES

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.

x-ds-tabs — A SWITCH THAT CROSS-FADES

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.

BESIDE OR BELOW, NEVER HALF OF EACH

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 SENTENCE HAS ITS OWN MEASURE

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.

THREE PANELS, ONE SCALE

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.

THE URL IS THE STATE

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>

THE ID IS ON THE WRAPPER, AND THE WRAPPER HAS NO BOX

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.

A TITLE IS MARKUP, SO IT CAN HOLD AN ICON

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.

TABS-FILL, THE SECOND SPECIMEN ABOVE

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.

TUNING ONE GROUP
/* One group, roomier and slower than the system default. */
.hg-install-tabs {
  --tab-pad: 16px 24px;
  --tab-motion: 400ms;
}

WHAT THE URL BEING THE STATE COSTS YOU

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.

THESE ARE LINKS, AND THE MARKUP DOES NOT PRETEND OTHERWISE

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.

THE PANEL ARRIVES AND LEAVES DIFFERENTLY, ON PURPOSE

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.