R RenDS v0.13.0

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.

1 Container

<ren-tabs> wrapper. Holds the tablist and the panels. Wires up the ARIA, keyboard, and selection state.

2 Tab list

.ren-tab-list with role="tablist". The row of triggers. Add a variant modifier for the visual style.

3 Tab

<button class="ren-tab" role="tab">. One per panel. Always a button — never a link — because activating it doesn't navigate.

4 Panel

.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.

<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.

Underline
Default style. Selected tab gets a 2 px accent underline.
Pills
Pills feel like a segmented control — good for filters and view modes.
Enclosed
Enclosed gives the "files in a folder" metaphor. Pair with a bordered panel.
Vertical
Profile settings — name, avatar, bio.
Default
Day stats.

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

Tab Moves focus into the tablist. Subsequent Tab moves to the active panel, then out of the component entirely.
/ (horizontal) or / (vertical) Move between tabs. In manual mode, focus only; in automatic mode, focus + select.
Enter / Space Select the focused tab (manual mode). Already selected in automatic mode.
Home / End Jump to the first or last tab.

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-controls on each tab pointing at its panel's id.
  • aria-labelledby on each panel pointing at its tab's id.
  • aria-selected="true|false" kept in sync with the active tab.
  • aria-orientation="vertical" when orientation="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}`); });