<tabs-elemental>
A list of tabs and the panels they show, per the APG Tabs pattern, in both orientations. Light DOM, no shadow root, nothing you wrote is moved or wrapped.
Arrow keys move along the strip and the panel follows. Home and End go to the ends, Tab leaves the strip for the panel.
The tabs are ordinary in-page links. Turn scripting off and this is a list of three links and three visible sections โ every word of it still readable, which is why the panels are not authored hidden.
Search this page for a word that is only in another panel โ the browser finds it and this tab set switches to the panel holding it.
<tabs-elemental>
<ul>
<li><a href="#install">Install</a></li>
<li><a href="#usage">Usage</a></li>
<li><a href="#support">Support</a></li>
</ul>
<div id="install">One npm install.</div>
<div id="usage">Import it and write the markup.</div>
<div id="support">Every browser with custom elements.</div>
</tabs-elemental>That markup is the page you would have had anyway: a list of links, and the sections they
point at. With scripting off the links jump and every panel is on screen โ nothing hides
without something to show it again. With scripting on, the stylesheet paints only the
first panel while the element is not yet defined โ and the theme paints the waiting list
as the strip it is about to become โ so the stack of sections does not flash and fold
into a tab set on the first frame, and nothing under it reflows; a #fragment deep link still shows the first
panel for that beat, until the element reads the hash. The gate's line is worth knowing:
a bundle that never arrives while scripting is on โ blocked, 404 โ leaves one panel
showing and links pointing at panels that stay hidden. The fallback covers scripting
turned off, not every way a script can fail to run.
Usage
import 'book-of-elementals/tabs';
@use "book-of-elementals/tabs/style.scss"; // structure
@use "book-of-elementals/tabs/theme.scss"; // the look, optional
Or the single-element bundle โ no build step, no script to write:
<script src="https://unpkg.com/book-of-elementals/dist/elementals/tabs.min.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/book-of-elementals/dist/elementals/tabs.min.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/book-of-elementals/dist/elementals/tabs-theme.min.css"
/>
It registers itself on include and upgrades on connect. Nothing on window, nothing to
instantiate, no init call to forget.
The markup it expects
- The tablist is the first
<ul>or<menu>in the element. Each tab is an<a>or a<button>inside its own<li>. - The panels are the element's other children, one per tab and in the same order.
Which panel belongs to which tab is read in three steps, first one that answers:
| On the tab | Pairs with |
|---|---|
href="#usage" |
#usage, wherever it is in the document |
aria-controls="usage" |
the same, for a tab that is a <button> |
| neither | the child in the same position |
The fragment is the one worth writing. It is a working link before the element exists, and
it states the pairing once instead of in an id and an aria-controls that can drift
apart. A <button> needs aria-controls to say the same thing โ or nothing at all, and
then position is the pairing:
<tabs-elemental>
<ul>
<li><button>First</button></li>
<li><button>Second</button></li>
</ul>
<section>โฆ</section>
<section>โฆ</section>
</tabs-elemental>
Position counts every child that is not the tablist, so it holds only while the panels are
the element's only other children โ slip a heading in between and it takes the first tab's
place, shifting every pairing after it. If the element has to hold anything else, name the
pairing with a fragment or aria-controls and position stops mattering.
API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
selected |
number | 0 |
Index of the selected tab. Reflected โ markup, script and CSS read the same thing. |
vertical |
boolean | false |
The strip runs down the page. The arrow keys go with it. |
manual |
boolean | false |
Arrows move focus without selecting; Enter or Space selects. |
sliding |
boolean | false |
Mark the selection with a bar that travels to the tab rather than a border on it. What it costs. |
Anything selected cannot be read as an index in range โ a typo, an index left behind by
a tab that has since been removed โ is the first tab. A tab set with nothing selected is
not a state this pattern has.
Properties
| Property | Type | Description |
|---|---|---|
selected |
number | Get/set the index. Writes the attribute. |
vertical |
boolean | Get/set. |
manual |
boolean | Get/set. |
sliding |
boolean | Get/set. Turning it off takes the measuring with it. |
tabs |
Element[] |
Read-only, in order. |
panels |
Element[] |
Read-only, in tab order. A tab with nothing to show is null. |
tablist |
Element |
Read-only. |
panelOf(tab) |
Element | null |
What one tab shows. |
wire() |
โ | Re-read the markup, see below. |
Events
tabs-select fires on every change โ click, arrow key, script, deep link or find-in-page โ
and bubbles:
const tabs = document.querySelector('tabs-elemental');
tabs.addEventListener('tabs-select', (e) => {
e.detail.index; // 2
e.detail.tab; // the <a> or <button>
e.detail.panel; // what it shows
});
tabs.selected = 2; // also fires it
What it writes
| Element | Attributes |
|---|---|
| the list | role="tablist", aria-orientation="vertical" when vertical, data-tabs-list |
each <li> |
role="none" |
| each tab | role="tab", aria-selected, aria-controls, tabindex, an id if it had none |
| each panel | role="tabpanel", aria-labelledby, hidden when not selected, data-tabs-panel, an id if it had none |
| the element | only under sliding: data-tabs-sliding, and --tabs-elemental-tab-start / --tabs-elemental-tab-size |
role="none" on the <li>s because inside a tablist the list semantics are noise โ a
screen reader counting list items in a tab strip is counting the wrong thing, and the
tablist already says how many tabs there are.
The panel gets tabindex="0" only while it is showing, and only when there is nothing
focusable inside it. That is the APG's answer to a panel a keyboard can neither reach nor
scroll, and it is only for that case: a panel full of links has enough tab stops already,
and a hidden panel is a box with nothing in it to stop on.
Keyboard
| Key | Action |
|---|---|
| Tab | Into the strip, then out of it to the panel |
โ โ, or โ โ when vertical |
Previous / next tab, wrapping at the ends |
| Home / End | First / last tab |
| Enter / Space | Select the focused tab โ the point of manual |
The strip is one stop on the way through the page, not one per tab: the selected tab is the only one Tab can land on and the arrows do the rest. That is the roving tabindex, and it is what makes a set of twelve tabs something a keyboard visitor can get past.
Keys off the strip's own axis are left alone. โ on a horizontal strip is the page scrolling, and taking that key would be taking it from a reader who is done with the tabs.
Vertical
One attribute. It writes aria-orientation="vertical", swaps the arrow keys onto the other
axis, and puts the strip beside the panels instead of above them:
A settings screen is where a vertical strip earns its keep: the labels are words rather than icons, there are more of them than fit across a column of text, and they read as a list of places rather than as a row of steps.
โ and โ move here โ โ and โ do nothing, because a vertical tablist does not answer to them.
The layout is a two-column grid on the element itself. The strip's width is its labels', and the panels take the rest.
<tabs-elemental vertical>
โฆ
</tabs-elemental>
There is no vertical-when the way <splitter-elemental> has one โ the
orientation is a keyboard contract as much as a layout, and a tab list that changed which
arrow key walks it as the window resized is a contract moving under the reader's hands. One
line does it where you want it anyway, and the element re-wires itself when the property
changes:
const wide = matchMedia('(min-width: 60rem)');
const sync = () => (tabs.vertical = wide.matches);
wide.addEventListener('change', sync);
sync();
Automatic or manual
By default the selection follows the focus: arrow onto a tab and its panel is showing. That is what the APG asks for wherever showing a panel costs nothing, which it does when the panel is already in the page.
manual is for the case where it does not. Arrows then move focus only, and
Enter or Space selects:
Arrow along this strip with the keyboard: focus moves, the panel does not, until you press Enter.
Which is the whole reason to want it โ arrowing past four tabs whose panels each fetch a report would start four requests nobody asked for.
If the panels are already on the page, leave it off. Automatic is fewer keystrokes for the same result.
<tabs-elemental manual>โฆ</tabs-elemental>
Tabs that change
Nothing watches the markup. Add a tab, remove one, rename them, and wire() is the one
call that says so:
tabs.querySelector('ul').insertAdjacentHTML(
'beforeend',
'<li><a href="#reports">Reports</a></li>',
);
tabs.insertAdjacentHTML('beforeend', '<div id="reports">โฆ</div>');
tabs.wire();
It re-reads everything and is safe to call as often as you like. A panel whose tab has gone
is handed back to the page as it was found โ nothing left hidden by an element that no
longer has anything to show it with. Under sliding it is also what
puts a tab that has just appeared under the observer.
That is a line on the pages that build their tabs, instead of a MutationObserver running
on every page that never touches them.
Deep links and find-in-page
A URL fragment pointing at a panel โ or at anything inside one โ selects that tab, on load
and on every hashchange. Which is also the no-script story arriving: the tabs are in-page
links, and following one before the bundle lands leaves exactly that fragment in the URL.
The panels nobody is looking at are hidden with hidden="until-found", so find-in-page
still searches them. Finding a word in one reveals it, and the element takes that as the
instruction to select its tab โ search this page for a word that only appears in a panel
that is not showing.
Note
until-found hides with content-visibility, which skips a box's contents and keeps the
box. The element's stylesheet strips that retained box back โ no padding, no border, no
background, no pointer events โ because all the panels share one grid cell and three
retained boxes over the one being read would paint across it and take its clicks. If you
style panels yourself, style [data-tabs-panel] and let that rule undo it when hidden.
The box is retained in the accessibility tree too, so a screen reader meets the panels
that are not showing as empty ones. That is the price of the text inside them staying
searchable, and it is the same trade <disclosure-elemental> makes.
One rule buys out of both halves of it, at the cost of find-in-page:
tabs-elemental > [data-tabs-panel][hidden] {
display: none;
}
The look
style.scss is structure only; theme.scss is the look and is optional โ a light-DOM
element cannot scope a look away from a page that did not ask for one. It is a strip with a
rule under it and the selected tab marked on that rule, mixed out of currentcolor so it
sits in whatever palette the page has:
| Property | Default | Description |
|---|---|---|
--tabs-elemental-gap |
0.25rem |
Between the tabs |
--tabs-elemental-inset |
0.5rem 0.75rem |
Padding inside a tab |
--tabs-elemental-panel-inset |
1rem |
Between the strip and the panel |
--tabs-elemental-radius |
0.375rem |
Corner radius of a tab |
--tabs-elemental-indicator-size |
2px |
Thickness of the rule, and of the selected mark |
--tabs-elemental-indicator |
currentcolor |
What marks the selected tab |
--tabs-elemental-border |
20% of currentcolor |
The rule the tabs sit on |
--tabs-elemental-hover |
10% of currentcolor |
Tab background under the pointer |
--tabs-elemental-muted |
65% of currentcolor |
Text of a tab that is not selected |
--tabs-elemental-duration |
250ms |
Travel of the sliding bar. Nothing else moves |
--tabs-elemental-easing |
ease-in-out |
Travel of the sliding bar |
Turn them in the Options tab and copy the rule out of the bottom of the panel โ the same table, with the values live:
<tabs-elemental>
<ul>
<li><a href="#one">Gap</a></li>
<li><a href="#two">Inset</a></li>
<li><a href="#three">Indicator</a></li>
</ul>
<div id="one">Between the tabs.</div>
<div id="two">Padding inside one.</div>
<div id="three">What marks the selected one.</div>
</tabs-elemental>The selected tab is marked with a border, and a border needs no script and can never
disagree with where the tab actually is. That is the default because it is free.
sliding buys the other one, and the price is on the label.
The mark is the strip's own rule filled in: the tab is pulled down over it by exactly the rule's thickness, so the two are one line rather than two stacked ones. Which is why the strip wraps instead of scrolling โ a scroll container clips at its padding box, and that is where the mark is drawn.
Under forced-colors the mark is repainted in Highlight, since it is the only thing
telling the selected tab apart. Nothing in the theme moves except the sliding bar, and
prefers-reduced-motion: reduce stops that one.
A bar that slides
sliding marks the selection with a bar that travels to the tab instead of a border sitting
on it. It is an attribute rather than a stylesheet because it cannot be drawn from CSS alone:
tabs are as wide as their labels, so something has to measure the selected one.
<tabs-elemental sliding>
<ul>
<li><a href="#slide-day">Day</a></li>
<li><a href="#slide-week">The last seven days</a></li>
<li><a href="#slide-month">Month</a></li>
</ul>
<div id="slide-day">Yesterday and today.</div>
<div id="slide-week">Seven of them.</div>
<div id="slide-month">Since the first.</div>
</tabs-elemental>The element measures the selected tab against the strip and writes two numbers onto itself โ
--tabs-elemental-tab-start, how far along the strip the tab begins, and
--tabs-elemental-tab-size, how much of it the tab takes. Both are logical: start is
measured from the edge the strip starts at, which is the right one in RTL, and a vertical
strip is measured down it instead. The theme draws one ::after on the strip from them, in
the same border band the border mark was in โ so the two marks are the same line, and
swapping one for the other moves nothing else on the page. --tabs-elemental-duration and
--tabs-elemental-easing are the travel, and prefers-reduced-motion: reduce switches it off.
Which is all it takes for vertical to be the same feature turned a quarter
turn โ the bar travels down the strip's own rule, and the arrow keys that move it are
โ and โ:
<tabs-elemental sliding vertical>
<ul>
<li><a href="#slide-general">General</a></li>
<li><a href="#slide-profile">Profile and photo</a></li>
<li><a href="#slide-billing">Billing</a></li>
</ul>
<div id="slide-general">Language, time zone, and what the week starts on.</div>
<div id="slide-profile">The name and the face other people see.</div>
<div id="slide-billing">The card on file, and what it was last charged.</div>
</tabs-elemental>It re-measures whenever the strip or any tab in it changes size โ a webfont landing, a
label translated, a tab added, the strip wrapping. Watching the strip alone is the bug every
measured indicator in the field ships with: a label that grows leaves the container exactly
the size it was, so nothing fires and the bar stays under where the tab used to be. It is
open against Base UI as this is written, and
MUI has had the same family of it for years โ measured before the webfont
lands, the container resized rather than
the window, a scrollbar appearing
later, the number of tabs
changing. Watching every tab is what answers
all four at once, which is what <marquee-elemental> does with its track for the same reason.
What it costs, honestly:
| Border, the default | sliding |
|
|---|---|---|
| Script | none | a ResizeObserver on the strip and every tab, and two rects per change |
| When the bundle never arrives | the mark is still there | no mark travels; the border stays |
| Forced colors | Highlight on the border |
the bar is dropped, the border comes back |
| A label that changes length | nothing to do | re-measured, without wire() โ the tab resized, so the observer fired |
The second row is the one to read twice. The theme keys the bar on data-tabs-sliding, which
the element writes only once it has measured โ never on [sliding], which is only the page
asking. No script, or a browser with no ResizeObserver, leaves the attribute standing with
no numbers behind it, and the selected tab keeps its border. A page that took the border off
on the strength of the attribute alone would be a tab set with nothing marking the selection.
Note
The two numbers are measured rect to rect, with no border taken off. The theme puts a border on the strip's block-end when it is horizontal and on its inline-end when it is vertical, and neither is on the edge being measured from โ but a page that borders the strip's other edges moves the bar by that much.
Styling hooks
tabs-elemental[selected="1"] {
} /* the host, reflected state */
tabs-elemental[data-tabs-sliding] > [data-tabs-list]::after {
} /* the sliding bar, once it has been measured */
tabs-elemental > [data-tabs-list] {
} /* the strip */
tabs-elemental > [data-tabs-list] > li > [aria-selected="true"] {
} /* the current tab */
tabs-elemental > [data-tabs-panel] {
} /* every panel */
tabs-elemental > [data-tabs-panel][hidden] {
} /* the ones not showing */
tabs-elemental:not(:defined) {
} /* before upgrade */
Note
These go on the <tabs-elemental> โ a class on it, .settings tabs-elemental, or the
element itself. The theme sets its defaults on the element, and a property set on an
element always beats one inherited from an ancestor, so
.settings { --tabs-elemental-indicator: โฆ } silently does nothing.
Layout
The element is a grid, and every panel is in the same cell. That is the one layout it
insists on, because the pattern does not work without it: panels laid out one after another
mean a page that jumps by the height of the last panel every time you change tabs, and
nothing in the markup says what that height was going to be.
horizontal vertical
โโโโโโโโโโโโโโโโ โโโโโโโโโฌโโโโโโโโโโโ
โ strip โ โ strip โ panels โ
โโโโโโโโโโโโโโโโค โ โ โ
โ panels โ โ โ โ
โโโโโโโโโโโโโโโโ โโโโโโโโโดโโโโโโโโโโโ
Both are two lines of grid-template-* and two grid-areas, so a page that wants the strip
somewhere else can say so in its own stylesheet. A panel that lives outside the element โ
paired by aria-controls or by a fragment โ is shown and hidden exactly the same way and
laid out by whatever it is inside; the grid is only about the children.
Tabs, accordion, or neither
Tabs hide most of what is on the page behind labels that have to be read as a set. That is worth it when the panels are alternatives โ a settings screen, one report per period, the same object seen four ways โ and a bad trade otherwise:
| Wanted | Reach for |
|---|---|
| Alternatives, one at a time | <tabs-elemental> |
| Sections a reader may want open at once | <accordion-elemental>, over native <details> |
| One thing shown and hidden | <disclosure-elemental> |
| Steps in an order | Neither โ a sequence with a Next button is not a tab set |
| Content that should print, or be found | Neither. Put it on the page |
The last row is the one people regret. Tabs are the cheapest way to make a long page look short and the most reliable way to make sure nobody reads two of the panels โ find-in-page still works here, but printing gets you one panel and a search engine gets you the markup without the reader.