Tabs
A tabbed navigation component for organizing content into panels, rendered as
<section is="root-tabs">.
Usage
<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.
<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>
<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>
<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.
<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.
<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.
<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:
feat/docs.main.feat/docs.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.
<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">.