Skip to content

Tabs

A tabbed navigation component for organizing content into panels, rendered as <section is="root-tabs">.

Usage

Language, time zone and the units everything is shown in.
Passwords, sessions and the devices signed in right now.
What is collected, what is shared, and how to stop both.
Language, time zone and the units everything is shown in.
Passwords, sessions and the devices signed in right now.
What is collected, what is shared, and how to stop both.
<section is="root-tabs">
  <nav role="tablist" aria-label="Settings">
    <button type="button" role="tab" aria-selected="true">General</button>
    <button type="button" role="tab">Security</button>
    <button type="button" role="tab">Privacy</button>
  </nav>
  <section role="tabpanel">Language, time zone and the units everything is shown in.</section>
  <section role="tabpanel">Passwords, sessions and the devices signed in right now.</section>
  <section role="tabpanel">What is collected, what is shared, and how to stop both.</section>
</section>

Three elements in a row: the strip, then one panel per tab, in the same order. Nothing else is needed - the ids, aria-controls, aria-labelledby, tabindex and which panels are hidden are all wired in onConnected, so tabs added dynamically (or hydrated from SSR markup) come out correct too. Call update() after mutating the tab set.

The panel is a panel: the page's ground, the frame at the button's width, the same radius step. That is the skin Accordion and Details wear, because a tab set is the same thing seen sideways - one of several labels opens one of several panels.

Sizes

data-size takes small and large; medium is the default and needs no value written. The step moves the tab's type and both its paddings, and the panel's padding with them, so a small tab set is smaller all the way through rather than a small strip on a full-size box.

54 packages built in 3.1s.
Nothing to report.
54 packages built in 3.1s.
Nothing to report.
<section is="root-tabs" data-size="small">
  <nav role="tablist" aria-label="Build log, small">
    <button type="button" role="tab" aria-selected="true">Output</button>
    <button type="button" role="tab">Warnings</button>
  </nav>
  <section role="tabpanel">54 packages built in 3.1s.</section>
  <section role="tabpanel">Nothing to report.</section>
</section>
54 packages built in 3.1s.
Nothing to report.
54 packages built in 3.1s.
Nothing to report.
<section is="root-tabs">
  <nav role="tablist" aria-label="Build log, medium">
    <button type="button" role="tab" aria-selected="true">Output</button>
    <button type="button" role="tab">Warnings</button>
  </nav>
  <section role="tabpanel">54 packages built in 3.1s.</section>
  <section role="tabpanel">Nothing to report.</section>
</section>
54 packages built in 3.1s.
Nothing to report.
54 packages built in 3.1s.
Nothing to report.
<section is="root-tabs" data-size="large">
  <nav role="tablist" aria-label="Build log, large">
    <button type="button" role="tab" aria-selected="true">Output</button>
    <button type="button" role="tab">Warnings</button>
  </nav>
  <section role="tabpanel">54 packages built in 3.1s.</section>
  <section role="tabpanel">Nothing to report.</section>
</section>

Fitted

fitted gives every tab an equal share of the strip's width and centres its label. It is for a strip that is the full width of something - a card header, a drawer, a column - where tabs sized to their own labels leave a ragged gap at the end.

128 conversations.
9 conversations.
2 conversations.
128 conversations.
9 conversations.
2 conversations.
<section is="root-tabs" fitted>
  <nav role="tablist" aria-label="Inbox">
    <button type="button" role="tab" aria-selected="true">All</button>
    <button type="button" role="tab">Unread</button>
    <button type="button" role="tab">Flagged</button>
  </nav>
  <section role="tabpanel">128 conversations.</section>
  <section role="tabpanel">9 conversations.</section>
  <section role="tabpanel">2 conversations.</section>
</section>

Orientation

Set aria-orientation="vertical" on the [role="tablist"] for a vertical tab strip: it renders beside the panels and navigates with Up/Down (APG) instead of Left/Right. Home/End jump to the ends in both orientations. The tabs keep their own shape - it is the axis that changes, not the skin - and the panel takes the width that is left.

Name, handle and the picture people see.
The card on file, and every invoice since the first one.
What we are allowed to interrupt you for.
Name, handle and the picture people see.
The card on file, and every invoice since the first one.
What we are allowed to interrupt you for.
<section is="root-tabs">
  <nav role="tablist" aria-orientation="vertical" aria-label="Account">
    <button type="button" role="tab" aria-selected="true">Profile</button>
    <button type="button" role="tab">Billing</button>
    <button type="button" role="tab">Notifications</button>
  </nav>
  <section role="tabpanel">Name, handle and the picture people see.</section>
  <section role="tabpanel">The card on file, and every invoice since the first one.</section>
  <section role="tabpanel">What we are allowed to interrupt you for.</section>
</section>

Flush

flush attaches the strip to the panel: no gap between the tabs, none between the strip and the panel, and no radius on the edges where the two meet. The selected tab keeps the panel's own ground and opens into it, so the pair reads as one surface with a tab on top rather than chips floating above a box.

14 headers sent, 11 received.
application/json, 2.4 kB.
DNS 4ms, TLS 31ms, TTFB 88ms.
14 headers sent, 11 received.
application/json, 2.4 kB.
DNS 4ms, TLS 31ms, TTFB 88ms.
<section is="root-tabs" flush>
  <nav role="tablist" aria-label="Request">
    <button type="button" role="tab" aria-selected="true">Headers</button>
    <button type="button" role="tab">Body</button>
    <button type="button" role="tab">Timing</button>
  </nav>
  <section role="tabpanel">14 headers sent, 11 received.</section>
  <section role="tabpanel">application/json, 2.4 kB.</section>
  <section role="tabpanel">DNS 4ms, TLS 31ms, TTFB 88ms.</section>
</section>

It composes with the other three. Vertical and flush moves the seam to the inline edge, so the strip attaches to the side of the panel instead of the top of it:

Deployed 12 minutes ago, from feat/docs.
Deployed 6 days ago, from main.
Deployed 12 minutes ago, from feat/docs.
Deployed 6 days ago, from main.
<section is="root-tabs" flush data-size="small">
  <nav role="tablist" aria-orientation="vertical" aria-label="Deployment">
    <button type="button" role="tab" aria-selected="true">Staging</button>
    <button type="button" role="tab">Production</button>
  </nav>
  <section role="tabpanel">Deployed 12 minutes ago, from <code>feat/docs</code>.</section>
  <section role="tabpanel">Deployed 6 days ago, from <code>main</code>.</section>
</section>

Opening a tab

active-tab is the index of the tab to open, counting from zero, and it is the declarative way to say it: aria-selected is a state the component owns and rewrites, so markup that sets it is only setting the initial value. Setting the attribute later moves the selection and fires root-tabs.tab-change, exactly as a click does; the activeTab property is the same operation from a script. An index that names no tab - a word, a negative, a number past the end - is ignored and leaves the open tab open.

3 items, 41.90 EUR.
Via Roma 1, 20121 Milano.
Card ending 4242.
3 items, 41.90 EUR.
Via Roma 1, 20121 Milano.
Card ending 4242.
<section is="root-tabs" active-tab="2">
  <nav role="tablist" aria-label="Order">
    <button type="button" role="tab">Basket</button>
    <button type="button" role="tab">Address</button>
    <button type="button" role="tab">Payment</button>
  </nav>
  <section role="tabpanel">3 items, 41.90 EUR.</section>
  <section role="tabpanel">Via Roma 1, 20121 Milano.</section>
  <section role="tabpanel">Card ending 4242.</section>
</section>

Attributes

Attribute Type Default Description
active-tab number 0 Index of the tab to open, counting from zero
data-size 'small' | 'large' - The step; medium is the default and takes no value
fitted boolean false Every tab takes an equal share of the strip's width
flush boolean false Attach the strip to the panel, nothing between them
aria-orientation (on [role="tablist"]) 'horizontal' | 'vertical' 'horizontal' Tab strip orientation + arrow-key axis
aria-label (on [role="tablist"]) string - Accessible name for the strip

Properties

Property Type Description
activeTab number Index of the open tab; assigning moves the selection and fires root-tabs.tab-change

Methods

Method Description
update() Re-wire the ARIA and re-apply the selection, after the tab set has been mutated

Events

Event Detail Description
root-tabs.tab-change { index } Fired when active tab changes

Keyboard

The strip is one tab stop: the open tab is the only one in the tab order, and the arrows move the selection inside it (APG's automatic-activation pattern).

Key Action
Left / Right Previous / next tab, wrapping at both ends - horizontal strip
Up / Down Previous / next tab, wrapping at both ends - vertical strip
Home / End First / last tab
Tab Leaves the strip for the open panel

Extends

HTMLElement - use with <section is="root-tabs">.