HyTabGroup
Usage examples on this page are written for Lit / plain HTML (
<hy-tab-group>). The same component ships asHyTabGroupin@whitespaceux/harmony-react(native React 19), with typed wrappers for Angular, Solid, Svelte, and Vue. The API reference below applies to all frameworks.
Child components: <hy-tab>, <hy-tab-panel> — used only inside this one.
A container component that manages a collection of tabs and their associated panels. Provides centralized state management, keyboard navigation, and accessibility features. Coordinates the relationship between tab elements and their corresponding content panels supporting both horizontal and vertical orientations.
Tab group, tab nav or toggle group?
Pick by what changes, never by look. Panels switch in place: a tab group. Each item is its own page and the URL changes: hy-tab-nav. A value is picked — a view mode (list, map, grid), a density, a unit — and the same content is shown another way: hy-toggle-group.
Layout Orientations
- horizontal (default): Tabs displayed in a row above content panels
- vertical-left: Tabs displayed in a column beside content panels with left indicator
- vertical-right: Tabs displayed in a column beside content panels with right indicator
Activation Modes
- automatic (default): Tabs activate immediately when focused with keyboard
- manual: Tabs require Enter/Space key press to activate after focusing
Keyboard Navigation
- Arrow Right/Down: Move focus to next tab
- Arrow Left/Up: Move focus to previous tab
- Home: Move focus to first tab
- End: Move focus to last tab
- Enter/Space: Activate focused tab (manual mode only)
State Management
The tab group automatically:
- Manages active state across tabs and panels
- Synchronizes orientation, emphasis and size properties to children
- Updates tabindex values for proper focus management
- Skips disabled tabs during keyboard navigation
When to use
- Several views of the same subject, of which people need one at a time: a record's overview, activity and settings.
- Sections that would make the page too long if shown together.
Do
- Keep tab labels short and parallel.
When not to use
- Panels that fetch data or are slow to render:
activation="manual", so arrowing across the tabs does not load each one. - Content people compare side by side: show the sections together, not in tabs.
- Steps done in order:
hy-stepper. - A set of commands:
hy-menu-button. - Only one panel: drop the tab group and give the content a heading.
- Stacked sections where several may be open at once:
hy-accordion.
Accessibility: your part
- Keep a text label on every
hy-tab. An icon goes beside the text, not in its place.
Examples
Basic usage
<hy-tab-group>
<hy-tab slot="tab" label="Tab 1" panel="panel1"></hy-tab>
<hy-tab slot="tab" label="Tab 2" panel="panel2"></hy-tab>
<hy-tab slot="tab" label="Tab 3" panel="panel3"></hy-tab>
<hy-tab-panel slot="panel" name="panel1">Content for tab 1</hy-tab-panel>
<hy-tab-panel slot="panel" name="panel2">Content for tab 2</hy-tab-panel>
<hy-tab-panel slot="panel" name="panel3">Content for tab 3</hy-tab-panel>
</hy-tab-group>Vertical orientation with icons
<hy-tab-group orientation="vertical-left" active-panel="settings">
<hy-tab slot="tab" label="Dashboard" panel="dashboard" icon="home"></hy-tab>
<hy-tab slot="tab" label="Analytics" panel="analytics" icon="bar-chart"></hy-tab>
<hy-tab slot="tab" label="Settings" panel="settings" icon="settings"></hy-tab>
<hy-tab-panel slot="panel" name="dashboard">
<h2>Dashboard</h2>
<p>Overview of your data and metrics.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="analytics">
<h2>Analytics</h2>
<p>Detailed analytics and reporting.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="settings">
<h2>Settings</h2>
<p>Configure your application preferences.</p>
</hy-tab-panel>
</hy-tab-group>Manual activation mode
<hy-tab-group activation="manual">
<hy-tab slot="tab" label="Profile" panel="profile-panel"></hy-tab>
<hy-tab slot="tab" label="Security" panel="security-panel"></hy-tab>
<hy-tab slot="tab" label="Billing" panel="billing-panel"></hy-tab>
<hy-tab-panel slot="panel" name="profile-panel">
<h3>Profile Settings</h3>
<p>Manage your personal information.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="security-panel">
<h3>Security Settings</h3>
<p>Configure password and authentication settings.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="billing-panel">
<h3>Billing Information</h3>
<p>View and manage your subscription.</p>
</hy-tab-panel>
</hy-tab-group>With disabled tab
<hy-tab-group orientation="horizontal" active-panel="overview">
<hy-tab slot="tab" label="Overview" panel="overview" icon="eye"></hy-tab>
<hy-tab slot="tab" label="Projects" panel="projects" icon="folder"></hy-tab>
<hy-tab slot="tab" label="Team" panel="team" icon="users"></hy-tab>
<hy-tab slot="tab" label="Reports" panel="reports" icon="file-text" disabled></hy-tab>
<hy-tab-panel slot="panel" name="overview">
<h2>Project Overview</h2>
<p>View your project statistics and metrics.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="projects">
<h2>Projects</h2>
<p>Manage your active projects.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="team">
<h2>Team Members</h2>
<p>View and manage team members.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="reports">
<h2>Reports</h2>
<p>Feature not yet available.</p>
</hy-tab-panel>
</hy-tab-group>Programmatic control
<hy-tab-group id="responsive-tabs" activation="manual">
<hy-tab slot="tab" label="Mobile" panel="mobile-view"></hy-tab>
<hy-tab slot="tab" label="Tablet" panel="tablet-view"></hy-tab>
<hy-tab slot="tab" label="Desktop" panel="desktop-view"></hy-tab>
<hy-tab-panel slot="panel" name="mobile-view">
<p>Mobile-optimized content and layout.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="tablet-view">
<p>Tablet-optimized content and layout.</p>
</hy-tab-panel>
<hy-tab-panel slot="panel" name="desktop-view">
<p>Desktop-optimized content and layout.</p>
</hy-tab-panel>
</hy-tab-group>
<script>
const tabGroup = document.getElementById('responsive-tabs');
// Set active panel programmatically
tabGroup.activePanel = 'desktop-view';
// Listen for tab changes
tabGroup.addEventListener('change', (event) => {
console.log('Active panel:', event.detail.activePanel);
console.log('Previous panel:', event.detail.previousPanel);
});
</script>Programmatic Methods
const tabGroup = document.querySelector('hy-tab-group');
// Change active panel
tabGroup.activePanel = 'settings';
// Change orientation
tabGroup.orientation = 'vertical-left';
// Get current active panel
console.log(tabGroup.activePanel);Async loading
For tabs whose content is fetched over the network, combine three primitives:
before-change— cancelable event fired when the user (click or keyboard) attempts to switch tabs. Callevent.preventDefault()to block the switch while a fetch is in flight. The event does NOT fire for programmatic updates (settingactivePaneldirectly), matching the HTMLchangeevent pattern — this prevents infinite loops when the consumer's handler eventually commits the switch.loading— whentrue, overlays the panel area with anhy-spinnerand setsaria-busy="true"on the panels container. The tab rail stays interactive so the user can still switch away or cancel.--hy-tab-group-panel-min-height— CSS custom property that sets a minimum height on the panel container, eliminating layout shifts when switching between panels whose content heights differ or arrive asynchronously.
Async panel loading
<style>
#async-tabs {
--hy-tab-group-panel-min-height: 22rem;
}
</style>
<hy-tab-group id="async-tabs" active-panel="overview">
<hy-tab slot="tab" label="Overview" panel="overview"></hy-tab>
<hy-tab slot="tab" label="Details" panel="details"></hy-tab>
<hy-tab-panel slot="panel" name="overview">…</hy-tab-panel>
<hy-tab-panel slot="panel" name="details" id="details-panel">…</hy-tab-panel>
</hy-tab-group>
<script>
const tabs = document.getElementById('async-tabs');
tabs.addEventListener('before-change', async (event) => {
if (event.detail.activePanel !== 'details') return;
event.preventDefault(); // block the switch while fetching
tabs.loading = true; // show spinner overlay
try {
const html = await fetchDetails();
document.getElementById('details-panel').innerHTML = html;
tabs.activePanel = 'details'; // programmatic set does NOT re-fire before-change
} finally {
tabs.loading = false;
}
});
</script>Tab-bar actions
The actions-start / actions-end slots host controls that apply to the whole tabbed view (e.g. a global search, a "New" primary). They render in the tab-bar as siblings of the role="tablist" element — never inside it — so the tablist stays pure and arrow-key roving is unaffected (the action controls are reached with Tab).
actions-start— leading edge (left in horizontal LTR, top of the rail in vertical).actions-end— trailing edge (right in horizontal LTR, bottom of the rail in vertical).- Both optional; when empty the tab-bar collapses to the prior layout (zero visual diff).
Each region becomes a labeled role="group" when it holds 2 or more controls (a single control needs no group). Name it with actions-start-label / actions-end-label. The controls are reached with Tab; the component manages arrow keys only inside the tablist. The region is not a role="toolbar" — that role promises arrow-key movement between the controls, which slotted fields, selects and toggle groups cannot join. For one tab stop with arrow keys between the actions, slot an hy-toolbar into the region:
<hy-toolbar slot="actions-end" label="View actions">
<hy-icon-button name="filter" label="Filter"></hy-icon-button>
<hy-icon-button name="sort" label="Sort"></hy-icon-button>
<hy-icon-button name="settings" label="View settings"></hy-icon-button>
</hy-toolbar>Tab-bar actions
<hy-tab-group active-panel="files">
<hy-tab slot="tab" label="Files" panel="files"></hy-tab>
<hy-tab slot="tab" label="Activity" panel="activity"></hy-tab>
<hy-button slot="actions-end" emphasis="soft">New</hy-button>
<hy-tab-panel slot="panel" name="files">…</hy-tab-panel>
<hy-tab-panel slot="panel" name="activity">…</hy-tab-panel>
</hy-tab-group>API
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
orientation | orientation | 'horizontal' | 'vertical-left' | 'vertical-right' | 'horizontal' | The orientation of the tab group. |
activePanel | active-panel | string | '' | The name of the initially active tab panel. |
activation | activation | 'automatic' | 'manual' | 'automatic' | Controls whether tabs are activated automatically on focus or manually on click/enter. |
emphasis | emphasis | 'solid' | 'soft' | 'outlined' | 'plain' | 'plain' | Visual chrome weight propagated to every child tab. - plain (default) — transparent rest, role underline indicator - soft — role-tinted pill on the active tab - outlined — boxed/bordered tabs - solid — fully filled active tab |
size | size | 'small' | 'medium' | 'large' | 'medium' | Type + padding rung propagated to every child tab. - small — label-md type on row-nav-md padding - medium (default) — body-md type on row-nav-lg padding - large — body-lg type on row-nav-lg padding |
loading | loading | boolean | false | When true, overlays the panel area with a spinner and marks the panels container aria-busy. The tab rail stays interactive so the user can still switch away. |
loadingLabel | loading-label | string | 'Loading' | Accessible name of the spinner shown while loading. Use for internationalization. |
actionsStartLabel | actions-start-label | string | '' | Accessible name applied as aria-label to the leading actions region when it becomes a labeled role="group" (2+ controls). Recommended whenever that region carries multiple controls so the toolbar has a name. |
actionsEndLabel | actions-end-label | string | '' | Accessible name for the trailing actions region when it becomes a labeled role="group". |
Events
| Event | Detail | Description |
|---|---|---|
change | — | Fired when the user switches tabs (click or keyboard), after before-change. Not fired for the initial selection or for programmatic activePanel updates. Detail: { activePanel: string, previousPanel: string } |
before-change | — | Cancelable. Fired on user-initiated tab switch (click or keyboard) before change. Call event.preventDefault() to block the switch and commit it later via tabGroup.activePanel = target. Not fired for programmatic activePanel updates. Detail: { activePanel: string, previousPanel: string } |
Slots
| Slot | Description |
|---|---|
tab | Tab elements that trigger panel changes |
panel | Tab panel elements that display content |
actions-start | View-global controls rendered at the leading edge of the tab-bar, beside the tablist. Becomes a labeled role="group" with 2+ controls. |
actions-end | View-global controls rendered at the trailing edge of the tab-bar. Becomes a labeled role="group" with 2+ controls. |
CSS Parts
| Part | Description |
|---|---|
base | The tab group's base wrapper element |
tab-bar | The row (column in vertical) wrapping the tablist and the action slots |
tab-list | The container for the tab elements |
actions-start | The leading tab-bar actions region |
actions-end | The trailing tab-bar actions region |
panels | The container for the tab panel elements |
loading-overlay | The spinner overlay rendered when loading is true |
CSS Custom Properties
The documented hooks. Every override hook the component reads — one per token it paints — is listed in @whitespaceux/harmony/theme-api.json, and @whitespaceux/harmony/component-bindings.json (the Harmony MCP get-bindings tool) says which part and state each one paints.
| Property | Default | Description |
|---|---|---|
--hy-tab-group-gap-layout-2xs | var(--hy-gap-layout-2xs) | Gap between vertical tabs. |
--hy-tab-group-gap-layout-sm | var(--hy-gap-layout-sm) | Gap between the tablist and the action regions, and between controls within a region. |
--hy-tab-group-panel-min-height | — | Minimum height of the panel container. Set this to the expected content height to eliminate layout shifts during async loads. Default: 0. |
--hy-tab-group-loading-overlay-background | transparent | Background of the loading overlay. Default: transparent (spinner floats over dimmed content). |