Tabs
stimeo--tabs
Tabbed panels. Moving with the arrow keys selects the tab you land on.
Implements the WAI-ARIA Tabs pattern. Moving through the tabs with the arrow keys selects the one you land on. Tab stops on the tab strip once, so it never gets in the way of walking the page from the keyboard. Which tab is selected is announced to screen readers, and only its panel is shown. Tabs pair to panels by their order.
Keyboard
| Key | Action |
|---|---|
| → / ← | Select the next / previous tab (wrapping). Under RTL the two swap. |
| Home / End | Select the first / last tab. |
<%# Markup for the tabs (APG Tabs / automatic activation) demo.
stimeo--tabs provides aria-selected, roving tabindex, arrow-key switching, and panel
visibility control. The initially selected tab has aria-selected="true" and
non-selected panels carry hidden (to prevent SSR flicker). %>
<div class="tabs" data-controller="stimeo--tabs">
<div class="tabs__list" role="tablist" aria-label="<%= t("components.tabs.demo.label") %>"
data-stimeo--tabs-target="list">
<button class="tabs__tab" role="tab" id="tab-overview" aria-controls="panel-overview"
aria-selected="true"
data-stimeo--tabs-target="tab"
data-action="click->stimeo--tabs#select
keydown->stimeo--tabs#onKeydown"><%= t(
"components.tabs.demo.overview"
) %></button>
<button class="tabs__tab" role="tab" id="tab-profile" aria-controls="panel-profile"
aria-selected="false" tabindex="-1"
data-stimeo--tabs-target="tab"
data-action="click->stimeo--tabs#select
keydown->stimeo--tabs#onKeydown"><%= t(
"components.tabs.demo.profile"
) %></button>
<button class="tabs__tab" role="tab" id="tab-billing" aria-controls="panel-billing"
aria-selected="false" tabindex="-1"
data-stimeo--tabs-target="tab"
data-action="click->stimeo--tabs#select
keydown->stimeo--tabs#onKeydown"><%= t(
"components.tabs.demo.billing"
) %></button>
</div>
<div class="tabs__panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview"
data-stimeo--tabs-target="panel"><%= t("components.tabs.demo.overview_content") %></div>
<div class="tabs__panel" role="tabpanel" id="panel-profile" aria-labelledby="tab-profile"
data-stimeo--tabs-target="panel" hidden><%= t(
"components.tabs.demo.profile_content"
) %></div>
<div class="tabs__panel" role="tabpanel" id="panel-billing" aria-labelledby="tab-billing"
data-stimeo--tabs-target="panel" hidden><%= t(
"components.tabs.demo.billing_content"
) %></div>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.events") %></span>
<span class="demo-readout__value" data-readout
data-change-template="<%= t("catalog.readout.tabs_change") %>">—</span>
</p>
/*
* Presentation-only styles for the tabs demo.
* The selected state is expressed via aria-selected (set by the library).
*/
.tabs__list {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
}
.tabs__tab {
padding: 0.5rem 1rem;
font-size: 0.95rem;
cursor: pointer;
color: var(--muted);
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
.tabs__tab[aria-selected="true"] {
color: var(--fg);
border-bottom-color: var(--accent);
}
.tabs__panel {
padding: 1rem 0.25rem;
}
// Selecting a tab reports which one moved: `index` is the new tab, `previous` the
// one it came from, and `total` how many there are. Reselecting the tab that is
// already selected says nothing. A real app uses this to sync a URL fragment or
// load a panel lazily; this demo writes the numbers into the readout.
//
// The event counts from 0, the way an array does. The readout counts from 1,
// the way the tabs read on screen — so "1 -> 2 of 3" is the first tab moving to
// the second. Same convention as the carousel and sortable demos.
//
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const stateReadout = demoStage.querySelector("[data-readout]");
const stateRoot = demoStage.querySelector("[data-controller~='stimeo--tabs']");
stateRoot?.addEventListener("stimeo--tabs:change", (event) => {
const { index, total, previous } = event.detail;
if (!stateReadout) return;
// The words come from the ERB, already localized; only the numbers are ours.
const ordinal = (position) => String(position + 1);
const moved = (stateReadout.dataset.changeTemplate ?? "{previous} -> {index} of {total}")
.replace("{previous}", ordinal(previous))
.replace("{index}", ordinal(index))
.replace("{total}", String(total));
stateReadout.textContent = `stimeo--tabs:change · ${moved}`;
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--tabs"
Targets
| Name | Description | Attribute |
|---|---|---|
tab
required
|
A tab button (role=tab) paired to the panel of the same index; carries aria-selected and roving tabindex. |
data-stimeo--tabs-target="tab" |
panel
required
|
A tab panel (role=tabpanel); its hidden attribute reflects whether its tab is selected. |
data-stimeo--tabs-target="panel" |
list
required
|
data-stimeo--tabs-target="list" |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Arrow/Home/End navigation with automatic activation: focusing a tab selects it. | stimeo--tabs#onKeydown |
select
|
Selects the clicked tab, updating aria-selected, tabindex, and panel visibility. |
stimeo--tabs#select |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires right after the selection moves; detail carries index, total and previous. detail.reason reports user / focus / pointer / api. |
stimeo--tabs:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-selected |
Each tab | "true" on the active tab, "false" on the others. You can render the initial tab server-side; if several are marked, the first wins. |
tabindex |
Each tab | 0 on the active tab, -1 on the others (roving tabindex). |
hidden |
Each panel | Only the selected tab's panel is shown. |