Fylgja Tabs
Tabs show one of several related panels in the same space, with a strip of buttons
to switch between them. HTML has no tabs element, so the strip is a row of buttons
in a role="tablist", and a small <tab-group> custom element handles selection
and keyboard navigation. On narrow screens the strip scrolls instead of wrapping.
Preview
Fylgja is a lightweight CSS library built on modern web standards.
Install the packages you need from npm, or load them straight from a CDN.
Write plain HTML. Fylgja styles the native elements for you.
<tab-group class="block">
<div role="tablist" aria-label="Getting started" class="tabs flex gap-sm scroll-x scroll-mask">
<button type="button" role="tab" id="tab-overview" aria-controls="tabpanel-overview" aria-selected="true" class="flex-none">Overview</button>
<button type="button" role="tab" id="tab-install" aria-controls="tabpanel-install" aria-selected="false" tabindex="-1" class="flex-none">Install</button>
<button type="button" role="tab" id="tab-usage" aria-controls="tabpanel-usage" aria-selected="false" tabindex="-1" class="flex-none">Usage</button>
</div>
<div role="tabpanel" id="tabpanel-overview" aria-labelledby="tab-overview" tabindex="0">
<p>Fylgja is a lightweight CSS library built on modern web standards.</p>
</div>
<div role="tabpanel" id="tabpanel-install" aria-labelledby="tab-install" tabindex="0" hidden>
<p>Install the packages you need from npm, or load them straight from a CDN.</p>
</div>
<div role="tabpanel" id="tabpanel-usage" aria-labelledby="tab-usage" tabindex="0" hidden>
<p>Write plain HTML. Fylgja styles the native elements for you.</p>
</div>
</tab-group><div
x-data="{
tab: 0,
select(index) {
const count = this.$refs.tablist.children.length;
this.tab = (index + count) % count;
this.$nextTick(() => this.$refs.tablist.children[this.tab].focus());
},
}"
>
<div
role="tablist"
aria-label="Getting started"
class="tabs flex gap-sm scroll-x scroll-mask"
x-ref="tablist"
@keydown.right.prevent="select(tab + 1)"
@keydown.left.prevent="select(tab - 1)"
@keydown.home.prevent="select(0)"
@keydown.end.prevent="select(-1)"
>
<button type="button" role="tab" id="tab-overview" aria-controls="tabpanel-overview" class="flex-none" :aria-selected="String(tab === 0)" :tabindex="tab === 0 ? 0 : -1" @click="tab = 0">Overview</button>
<button type="button" role="tab" id="tab-install" aria-controls="tabpanel-install" class="flex-none" :aria-selected="String(tab === 1)" :tabindex="tab === 1 ? 0 : -1" @click="tab = 1">Install</button>
<button type="button" role="tab" id="tab-usage" aria-controls="tabpanel-usage" class="flex-none" :aria-selected="String(tab === 2)" :tabindex="tab === 2 ? 0 : -1" @click="tab = 2">Usage</button>
</div>
<div role="tabpanel" id="tabpanel-overview" aria-labelledby="tab-overview" tabindex="0" x-show="tab === 0">
<p>Fylgja is a lightweight CSS library built on modern web standards.</p>
</div>
<div role="tabpanel" id="tabpanel-install" aria-labelledby="tab-install" tabindex="0" x-show="tab === 1" x-cloak>
<p>Install the packages you need from npm, or load them straight from a CDN.</p>
</div>
<div role="tabpanel" id="tabpanel-usage" aria-labelledby="tab-usage" tabindex="0" x-show="tab === 2" x-cloak>
<p>Write plain HTML. Fylgja styles the native elements for you.</p>
</div>
</div>.tabs {
--tabs-line: color-mix(in oklab, var(--root-bg) 75%, var(--root-fg));
box-shadow: inset 0 -1px 0 var(--tabs-line);
[role="tab"] {
--btn-radius: 0.5rem 0.5rem 0 0;
--btn-border-width: 0 0 2px;
--btn-stroke: transparent;
--outline-offset: -2px;
}
[aria-selected="true"] {
--btn-stroke: var(--brand);
--btn-active-bg: transparent;
}
&[aria-orientation="vertical"] {
box-shadow: none;
border-inline-end: 1px solid var(--tabs-line);
[role="tab"] {
--btn-radius: 0;
--btn-border-width: 0;
justify-content: start;
margin-inline-end: -1px;
border-inline-end-width: 2px;
border-start-start-radius: 0.5rem;
border-end-start-radius: 0.5rem;
}
}
}
The <tab-group> script
class TabGroup extends HTMLElement {
/**
* The tabs, read on use so the element works even when it is defined before its children are parsed.
* @returns {HTMLButtonElement[]}
*/
get tabs() {
return [...this.querySelectorAll('[role="tab"]')];
}
connectedCallback() {
this.addEventListener("click", this);
this.addEventListener("keydown", this);
}
/**
* Selects a tab on click, and moves between tabs with the arrow, Home, and End keys.
* @param {MouseEvent | KeyboardEvent} event
* @returns {void}
*/
handleEvent(event) {
const current = event.target.closest('[role="tab"]');
if (!current) return;
if (event.type === "click") {
this.select(current);
return;
}
const tabs = this.tabs;
const index = tabs.indexOf(current);
const last = tabs.length - 1;
const vertical = current.parentElement.getAttribute("aria-orientation") === "vertical";
const next = {
[vertical ? "ArrowDown" : "ArrowRight"]: index === last ? 0 : index + 1,
[vertical ? "ArrowUp" : "ArrowLeft"]: index === 0 ? last : index - 1,
Home: 0,
End: last,
}[event.key];
if (next === undefined) return;
event.preventDefault();
this.select(tabs[next]);
tabs[next].focus();
}
/**
* Marks one tab as selected and shows its panel, hiding the others.
* @param {HTMLButtonElement} tab
* @returns {void}
*/
select(tab) {
for (const item of this.tabs) {
const selected = item === tab;
item.setAttribute("aria-selected", String(selected));
item.tabIndex = selected ? 0 : -1;
document.getElementById(item.getAttribute("aria-controls")).hidden = !selected;
}
}
}
customElements.define("tab-group", TabGroup);
Usage
- Wrap the strip and panels in
<tab-group>, and load the script once per page. - Each tab is a
<button role="tab">with anidandaria-controlspointing at its panel. Render the selected state in the markup:aria-selected="true"on the first tab,aria-selected="false"andtabindex="-1"on the rest. - Each panel is a
role="tabpanel"witharia-labelledbypointing back at its tab. Addhiddento every panel except the selected one. scroll-xlets the strip scroll when the tabs do not fit,scroll-maskfades the overflowing edge, andflex-nonekeeps each tab from shrinking. When there are more tabs than fit, the strip scrolls sideways instead of wrapping onto a second row, on mobile and desktop alike.- The
tabsclass draws a line under the whole strip and gives the selected tab a brand colored bottom border. It only sets base button variables, so the tabs keep their hover and focus styles.
The Alpine tab shows the same component with x-data instead of a custom element.
Anatomy
| Part | Responsibility |
|---|---|
<tab-group> |
Listens for clicks and keys, and switches the selected tab. |
role="tablist" |
The scrollable strip of tabs, named with aria-label. |
<button role="tab"> |
One tab. aria-selected marks the active one. |
role="tabpanel" |
The content for one tab, hidden unless its tab is selected. |
Accessibility
- No native element carries the tab roles, so this is the one place where
roleis the right tool. Screen readers announce each tab with its position, such as “tab 1 of 3”, and whether it is selected. - Only the selected tab is in the Tab order. The Left and Right arrow keys (Up and Down for vertical tabs) move between tabs, Home and End jump to the first and last, and Tab moves on into the panel.
- Panels have
tabindex="0", so keyboard users can reach a panel that holds no focusable content. - Name the tablist with
aria-labeloraria-labelledby, and keep the tab labels short and unique.
Variants
Vertical tabs
Add aria-orientation="vertical" to the tablist and stack it with flex-col next
to the panels. The line moves to the side of the strip, and the script switches to
the Up and Down arrow keys. Vertical tabs suit settings pages with more tabs than fit
in a row.
Your name, photo, and bio as other people see them.
Choose which updates reach you by email.
Change your password and manage signed in devices.
<tab-group class="flex gap">
<div role="tablist" aria-label="Account settings" aria-orientation="vertical" class="tabs flex-col flex-none">
<button type="button" role="tab" id="tab-profile" aria-controls="tabpanel-profile" aria-selected="true">Profile</button>
<button type="button" role="tab" id="tab-notifications" aria-controls="tabpanel-notifications" aria-selected="false" tabindex="-1">Notifications</button>
<button type="button" role="tab" id="tab-security" aria-controls="tabpanel-security" aria-selected="false" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="tabpanel-profile" aria-labelledby="tab-profile" tabindex="0">
<p>Your name, photo, and bio as other people see them.</p>
</div>
<div role="tabpanel" id="tabpanel-notifications" aria-labelledby="tab-notifications" tabindex="0" hidden>
<p>Choose which updates reach you by email.</p>
</div>
<div role="tabpanel" id="tabpanel-security" aria-labelledby="tab-security" tabindex="0" hidden>
<p>Change your password and manage signed in devices.</p>
</div>
</tab-group>Related: Accordion, Pagination.
FAQ
Why is there no version without JavaScript?
HTML has no tabs element. A group of <details> elements can be styled to look like tabs, but screen readers announce them as disclosures, every tab can be closed, and the panel ends up in a different reading order than it appears on screen. A few lines of script give real tab semantics instead.
What happens when the tabs do not fit?
The strip scrolls sideways, on mobile and desktop alike. The scroll-mask utility fades the edge that has more tabs behind it, and the arrow keys bring the focused tab into view.
What do users see without JavaScript?
The first panel, since the markup renders it as selected and hides the others. If every panel must stay reachable without a script, use an Accordion instead.
When should I use tabs instead of an accordion?
Use tabs for a few parallel views where seeing the labels side by side helps, such as Overview, Specs, and Reviews. Use an Accordion for many sections or content that people read from top to bottom.