Tabs

Organize content with style.

zweiundeins <sb-tabs> tablist segmented switcher Since 2026-09-21 MIT licence 1.8 kB0 Open in playground Edit on GitHub

Playground

A segmented tab bar with keyboard support. Labels come from a JSON attribute. Each panel is a slotted element whose slot is its label lowercased, with a hyphen for every run of anything but letters and digits: slot="docs" for "Docs", slot="self-host" for "Self-host", slot="übersicht" for "Übersicht". When two labels give the same slot ("C++" and "C#" both give c), the later one gets its index appended: c-1.

Examples

With panels

A light, fast shuttle built for orbital hops.

Payload 4t · Δv 9.4 km/s · 2 engines

Commander, pilot and one very good dog.

<sb-tabs labels='["Overview","Specs","Crew"]' style="inline-size: 100%">
  <p slot="overview">A light, fast shuttle built for orbital hops.</p>
  <p slot="specs">Payload 4t · Δv 9.4 km/s · 2 engines</p>
  <p slot="crew">Commander, pilot and one very good dog.</p>
</sb-tabs>

Follow the selection

The live index is the selected property, and input fires on every move (a click or an arrow key), so data-bind follows it with the __prop modifier. Declare the signal first; setting it selects a tab.

Selected index:

<div data-signals:_tab="1">
  <sb-tabs labels='["Home","Docs","API"]' data-bind:_tab__prop.selected></sb-tabs>
  <p>Selected index: <strong data-text="$_tab"></strong></p>
</div>

With commands

Give it a name, and it emits sb-change with { name, value, label } (value is the index) when the selection is committed: ready to post as a command (sb-tab-change fires at the same moment with { index, label }). A click commits at once; the arrow keys commit after a 250 ms pause, so running through the tabs sends one command, not one per tab (whose answers would pull the selection back one by one). With confirm, it sets :state(pending) until the server's re-rendered selected attribute matches, and revert() goes back to the server's value when a command is rejected. The panels are light DOM, so events from their content (a field's change, another component's sb-change) bubble through sb-tabs too: check evt.target === el. See Commands and components and the Showcase.

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Fonts: the tab labels use your page's font.
  • Colours: the strip is --sb-surface-inset with a --sb-border edge. Tabs are --sb-text-2 (--sb-text-1 on hover) with a --sb-border outline; the selected tab fills with --sb-brand-light and writes in --sb-bg. The focus ring is --sb-focus-ring, corners --sb-control-radius. When the tabs don't fit, the strip scrolls sideways with a thin --sb-border scrollbar, and the selected tab scrolls into view.
  • Parts: tablist (the strip), tab (every tab) and panel (every panel). The selected tab is also selected, so ::part(tab selected) styles only that one; it moves with the selection. Your page's ::part() rules win over the component's own, without !important.

Low Earth orbit, 420 km.

Commander, pilot and one very good dog.

<style>
  .my-tabs { --sb-brand-light: var(--sb-accent); --sb-control-radius: 0; }
  .my-tabs::part(tab) { padding: 0.35rem 0.75rem; font-size: 0.8125rem; }
  .my-tabs::part(tab selected) { text-decoration: underline; }
</style>
<sb-tabs class="my-tabs" labels='["Orbit","Crew"]'>
  <p slot="orbit">Low Earth orbit, 420 km.</p>
  <p slot="crew">Commander, pilot and one very good dog.</p>
</sb-tabs>

Accessibility

It follows the WAI-ARIA tabs pattern: role="tablist", tab and tabpanel, a roving tabindex, and arrow keys, Home and End for navigation; in a right-to-left layout the arrows follow the mirrored strip. Name the tab list with aria-label on sb-tabs: it is forwarded to the tablist inside (the site's own install tabs say aria-label="Installation method"). In forced-colors mode (Windows High Contrast) the selected tab is drawn in Highlight and the focus as an outline.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-tabs labels='["Home","Docs","API"]'></sb-tabs>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/tabs@5c10a36a64dc/tabs.bundle.min.js" integrity="sha384-kiKBs+uYghB25P+Y1A0tjR6RtpPMRwMg2Y8QQSlIQ+TOOpg2CtDXH7fwuYD9d9uK"></script>

<sb-tabs labels='["Home","Docs","API"]'></sb-tabs>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/tabs@5c10a36a64dc/tabs.min.js": "sha384-lEkMjsmZLZsA1oqP7XrjCKNvzcfhS97hr6uB/gQVyZQF35CADNu/ORak4vpmG57y"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/tabs@5c10a36a64dc/tabs.min.js" integrity="sha384-lEkMjsmZLZsA1oqP7XrjCKNvzcfhS97hr6uB/gQVyZQF35CADNu/ORak4vpmG57y"></script>

<sb-tabs labels='["Home","Docs","API"]'></sb-tabs>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/tabs@5c10a36a64dc/tabs.bundle.min.js": "sha384-kiKBs+uYghB25P+Y1A0tjR6RtpPMRwMg2Y8QQSlIQ+TOOpg2CtDXH7fwuYD9d9uK"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-tabs labels='["Home","Docs","API"]'></sb-tabs>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-tabs>
    • tabs.bundle.min.js one file 1.8 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/tabs/tabs.bundle.min.js"></script>

<sb-tabs labels='["Home","Docs","API"]'></sb-tabs>

Or copy the module files, keeping each component's folder, and load each component's module:

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/tabs/tabs.min.js"></script>

<sb-tabs labels='["Home","Docs","API"]'></sb-tabs>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
tabs.bundle.min.js 4.4 kB2.1 kB1.8 kB
As module files: one request, 1.8 kB minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
tabs.js 10.3 kB4.1 kB3.6 kB1.8 kB

API reference

Props

AttributeTypeDefaultDescription
labelsarray[ "Home", "Docs", "API" ]Tab labels, as a JSON array.
selectednumber0Index of the selected tab (past the last tab means the last one). A new index from the server wins; a removed attribute is ignored.
confirmbooleanfalseServer-confirmed value: :state(pending) while the local selection differs from the server's selected attribute (see revert()).
namestring""Name reported in sb-change (e.g. the field of a command).

Slots

NameDescription
<label-slug>Panel content per tab: slot="docs" for a tab labelled "Docs", slot="übersicht" for "Übersicht". A slot name an earlier tab already has gets the tab's index appended ("c-1" for a second tab whose label gives "c").

Events

NameDescription
inputThe user moved the selection (a click or an arrow key); host.selected is the new index.
changeThe selection is committed: at once for a click, after a short pause for the keyboard.
sb-changeSame moment. detail: { name, value, label } (value is the index): ready for a command.
sb-tab-changeSame moment. detail: { index, label }.