Composite
Tabs Requires JS
Switch between content panels without leaving the page. Implements the ARIA tablist pattern with full keyboard navigation, three visual variants, and horizontal or vertical orientation. Wrap your tabs in <ren-tabs> and the keyboard, ARIA, and selection state are wired automatically.
About
Overview
Tabs let a user step through several views of related content within the same screen — settings categories, dataset filters, code-vs-preview toggles. The component handles the parts that are easy to get wrong: roving tabindex, arrow-key navigation, ARIA selection state, panel visibility.
You write the markup as if it were already accessible (semantic <button role="tab"> elements paired with <div role="tabpanel">), wrap the whole thing in <ren-tabs>, and the component fills in aria-controls, aria-labelledby, aria-selected, ids, and the hidden state on inactive panels.
Tabs are for sibling views, not navigation. If clicking a "tab" should change the URL or load a new page, use ren-nav or plain links instead. Tabs imply that all the content already exists on this page and you're just switching the view.
Parts
Anatomy
A tabs component is built from three nested layers. Each has a single responsibility.
Assembled
The Overview panel content lives here. Switch tabs above to see the others.
The Activity panel — typically a feed of recent events.
The Settings panel — a form for configuring the resource.
<ren-tabs> wrapper. Holds the tablist and the panels. Wires up the ARIA, keyboard, and selection state.
.ren-tab-list with role="tablist". The row of triggers. Add a variant modifier for the visual style.
<button class="ren-tab" role="tab">. One per panel. Always a button — never a link — because activating it doesn't navigate.
.ren-tab-panel with role="tabpanel". One per tab, in the same order. Inactive panels get hidden automatically.
Live
Demo
Click the tabs below or focus one and use the arrow keys. Tab and Shift+Tab move in and out of the tablist; arrow keys move between tabs.
Project overview
A quick summary of the project's status, owner, and last activity. Most users land here first.
Recent activity
A feed of the last few events — commits, comments, status changes. Filter by member from the sidebar.
Team members
People with access to this project, their roles, and the last time they were active. Owners can invite or remove.
Project settings
Name, description, visibility, danger-zone actions. Changes are saved as you go.
<ren-tabs>
<div role="tablist" class="ren-tab-list ren-tab-list-underline" aria-label="Project sections">
<button role="tab" class="ren-tab" type="button">Overview</button>
<button role="tab" class="ren-tab" type="button">Activity</button>
<button role="tab" class="ren-tab" type="button">Members</button>
<button role="tab" class="ren-tab" type="button">Settings</button>
</div>
<div role="tabpanel" class="ren-tab-panel">Overview content</div>
<div role="tabpanel" class="ren-tab-panel" hidden>Activity content</div>
<div role="tabpanel" class="ren-tab-panel" hidden>Members content</div>
<div role="tabpanel" class="ren-tab-panel" hidden>Settings content</div>
</ren-tabs>
Shapes
Variants
Three visual styles, two orientations, two activation modes. Pick by context: underline for primary navigation within a page, pills for filter / segmented controls, enclosed for "documents in folders" metaphors.
default-value="1".Reference
API
CSS classes
| Class | Effect |
|---|---|
.ren-tabs |
Container. Sets up the inline-size container query so tabs can adapt to their available width. |
.ren-tab-list |
The row of tab buttons. Used with role="tablist". Has a default underline divider. |
.ren-tab-list-underline |
Default visual variant. Selected tab gets a 2 px accent underline; the rest sit on a divider line. |
.ren-tab-list-pills |
Segmented-control look. Selected tab fills with --color-fill. No underline divider. |
.ren-tab-list-enclosed |
"Folder tab" look. Selected tab merges into the panel below with a shared border. |
.ren-tab |
Single tab button. Always a <button type="button"> with role="tab". |
.ren-tab-panel |
One content region. Used with role="tabpanel". Inactive panels get hidden. |
Web Component attributes
Set on the <ren-tabs> wrapper. Re-rendered on the fly when changed.
| Attribute | Type | Default | Notes |
|---|---|---|---|
activation |
"manual" | "automatic" | "manual" | Manual: arrow keys move focus; Enter / Space selects. Automatic: arrow keys select immediately. |
orientation |
"horizontal" | "vertical" | "horizontal" | Sets which arrow keys cycle the tabs (Left/Right vs Up/Down) and the aria-orientation attribute. |
default-value |
number | string | 0 | Index (e.g. "1") or tab id to select on mount. |
JavaScript API
| Member | Description |
|---|---|
selectedIndex |
Getter. Index of the currently selected tab. |
selectedTab |
Getter. The selected <button role="tab"> element, or null if none. |
selectedPanel |
Getter. The currently visible <div role="tabpanel"> element. |
tabs / panels |
Getters. Arrays of all tab and panel elements, in document order. |
selectTabByIndex(index) |
Programmatically select a tab by its index. Fires ren-tab-change. |
selectTabById(id) |
Programmatically select a tab by its id. Fires ren-tab-change. |
Events
| Event | Detail |
|---|---|
ren-tab-change |
Bubbles and is composed. Fires whenever a tab becomes selected. event.detail: { tab, panel, index, id }. |
const tabs = document.querySelector('ren-tabs');
tabs.addEventListener('ren-tab-change', (e) => {
console.log('Now showing:', e.detail.id, '(index', e.detail.index + ')');
});
// Programmatic switch
tabs.selectTabByIndex(2);Inclusive by default
Accessibility
The component implements the WAI-ARIA Tabs pattern end-to-end. You provide the markup; it provides the wiring.
Keyboard
ARIA wiring
You don't need to write any of the ARIA attributes — the component sets them on mount. What gets wired:
role="tablist",role="tab",role="tabpanel"if missing.aria-controlson each tab pointing at its panel'sid.aria-labelledbyon each panel pointing at its tab'sid.aria-selected="true|false"kept in sync with the active tab.aria-orientation="vertical"whenorientation="vertical".
Manual vs automatic activation
Default is manual — arrow keys move focus, the user explicitly presses Enter or Space to switch panels. Use this when each panel is expensive to render or has side effects (data fetching, scroll position reset).
Use automatic when panels are cheap and the user is browsing — arrow keys feel snappier when each one immediately swaps the view.
Always provide an aria-label on the tablist when there's no visible heading describing what the tabs control. "Project sections" reads well; an unlabelled tablist is announced as just "tab list, 4 tabs" with no context.
Motion
The active-tab indicator uses the --transition-tactile motion token. Under prefers-reduced-motion: reduce the transition is disabled — the underline / fill snaps instead of sliding.
Patterns
Examples
Settings page
A vertical tablist on the left, panels on the right. The pattern most settings UIs converge on.
<ren-tabs orientation="vertical">
<div class="settings-grid"> <!-- your own grid layout -->
<div role="tablist" class="ren-tab-list" aria-label="Settings" aria-orientation="vertical">
<button role="tab" class="ren-tab" type="button">Profile</button>
<button role="tab" class="ren-tab" type="button">Account</button>
<button role="tab" class="ren-tab" type="button">Billing</button>
<button role="tab" class="ren-tab" type="button">Notifications</button>
</div>
<div>
<div role="tabpanel" class="ren-tab-panel">Profile form</div>
<div role="tabpanel" class="ren-tab-panel" hidden>Account form</div>
<div role="tabpanel" class="ren-tab-panel" hidden>Billing</div>
<div role="tabpanel" class="ren-tab-panel" hidden>Notifications</div>
</div>
</div>
</ren-tabs>Filter pills
Pills work well as a filter row above a list. Pair with activation="automatic" so the list updates as the user arrow-keys through the options.
<ren-tabs activation="automatic">
<div role="tablist" class="ren-tab-list ren-tab-list-pills" aria-label="Filter projects">
<button role="tab" class="ren-tab" type="button">All</button>
<button role="tab" class="ren-tab" type="button">Active</button>
<button role="tab" class="ren-tab" type="button">Archived</button>
</div>
<div role="tabpanel" class="ren-tab-panel">... rendered list ...</div>
<div role="tabpanel" class="ren-tab-panel" hidden>...</div>
<div role="tabpanel" class="ren-tab-panel" hidden>...</div>
</ren-tabs>Lazy-load panels on tab change
Listen to ren-tab-change and fetch panel content on demand instead of rendering everything upfront.
const tabs = document.querySelector('#project-tabs');
tabs.addEventListener('ren-tab-change', async (e) => {
const panel = e.detail.panel;
if (panel.dataset.loaded) return;
panel.innerHTML = '<div class="ren-skeleton skeleton-text"></div>';
const data = await fetch(`/api/${e.detail.id}`).then(r => r.json());
panel.innerHTML = renderPanel(data);
panel.dataset.loaded = 'true';
});Sync the active tab with the URL hash
Useful for shareable links and back-button support.
const tabs = document.querySelector('#project-tabs');
// Restore from hash on load
if (location.hash) {
tabs.selectTabById(location.hash.slice(1));
}
// Update hash when the user switches tabs
tabs.addEventListener('ren-tab-change', (e) => {
history.replaceState(null, '', `#${e.detail.id}`);
});