Tabs
Accessible tabs (roving focus, arrow keys) — underline or pill, horizontal or vertical, full-width, icons; data-driven or composed, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Tabs switch between panels. Built for accessibility — roving focus and
arrow-key navigation — with two looks (underline and pill), optional icons,
full-width layout, and disabled tabs. Lay them out horizontally (default) or
vertically, and activate automatically (arrow to select) or manually
(arrow to focus, Enter/Space to select). Data-driven via items, controlled or
uncontrolled. In Angular it's <bpdm-tabs> with the same inputs.
Usage
Pass items — each with a value, label, and content. defaultValue sets the
initial tab (or control it with value + onValueChange).
Key metrics and recent activity for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview', content: <p>Key metrics and recent activity.</p> },
{ value: 'activity', label: 'Activity', content: <p>A feed of deploys, comments, and reviews.</p> },
{ value: 'settings', label: 'Settings', content: <p>Manage members, roles, and integrations.</p> },
];
export function WorkspaceTabs() {
return <Tabs items={items} defaultValue="overview" />;
}Each tab's content is a <ng-template> referenced from the items array.
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'workspace-tabs',
imports: [BpdmTabs],
templateUrl: './workspace-tabs.html',
})
export class WorkspaceTabs {
readonly overview = viewChild.required<TemplateRef<unknown>>('overview');
readonly activity = viewChild.required<TemplateRef<unknown>>('activity');
readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
readonly items = computed<TabItem[]>(() => [
{ value: 'overview', label: 'Overview', content: this.overview() },
{ value: 'activity', label: 'Activity', content: this.activity() },
{ value: 'settings', label: 'Settings', content: this.settings() },
]);
}<bpdm-tabs [items]="items()" defaultValue="overview" />
<ng-template #overview><p>Key metrics and recent activity.</p></ng-template>
<ng-template #activity><p>A feed of deploys, comments, and reviews.</p></ng-template>
<ng-template #settings><p>Manage members, roles, and integrations.</p></ng-template>Pill variant
variant="pill" swaps the underline indicator for a filled active tab — good for
compact, segmented switches.
Key metrics and recent activity for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
];
export function PillTabs() {
return <Tabs items={items} defaultValue="overview" variant="pill" />;
}import { Component } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'pill-tabs',
imports: [BpdmTabs],
template: `<bpdm-tabs [items]="items" defaultValue="overview" variant="pill" />`,
})
export class PillTabs {
readonly items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
];
}With icons
Give a tab an icon for a leading glyph.
Key metrics for your workspace.
In React the icon is any node — here a lucide-react glyph.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
import { Gauge, Activity, Cog } from 'lucide-react';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview', icon: <Gauge />, content: <p>Key metrics for your workspace.</p> },
{ value: 'activity', label: 'Activity', icon: <Activity />, content: <p>Deploys, comments, and reviews.</p> },
{ value: 'settings', label: 'Settings', icon: <Cog />, content: <p>Members, roles, and integrations.</p> },
];
export function IconTabs() {
return <Tabs items={items} defaultValue="overview" />;
}In Angular the icon is a <ng-template> too — reference it from the items array.
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'icon-tabs',
imports: [BpdmTabs],
templateUrl: './icons.html',
})
export class IconTabs {
readonly overview = viewChild.required<TemplateRef<unknown>>('overview');
readonly activity = viewChild.required<TemplateRef<unknown>>('activity');
readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
readonly gauge = viewChild.required<TemplateRef<unknown>>('gauge');
readonly pulse = viewChild.required<TemplateRef<unknown>>('pulse');
readonly cog = viewChild.required<TemplateRef<unknown>>('cog');
readonly items = computed<TabItem[]>(() => [
{ value: 'overview', label: 'Overview', icon: this.gauge(), content: this.overview() },
{ value: 'activity', label: 'Activity', icon: this.pulse(), content: this.activity() },
{ value: 'settings', label: 'Settings', icon: this.cog(), content: this.settings() },
]);
}<bpdm-tabs [items]="items()" defaultValue="overview" />
<ng-template #overview><p>Key metrics for your workspace.</p></ng-template>
<ng-template #activity><p>Deploys, comments, and reviews.</p></ng-template>
<ng-template #settings><p>Members, roles, and integrations.</p></ng-template>
<ng-template #gauge><!-- your icon svg --></ng-template>
<ng-template #pulse><!-- your icon svg --></ng-template>
<ng-template #cog><!-- your icon svg --></ng-template>Full width
fullWidth stretches the tabs to fill the row equally.
Key metrics and recent activity for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
];
export function FullWidthTabs() {
return <Tabs items={items} defaultValue="overview" fullWidth />;
}import { Component } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'full-width-tabs',
imports: [BpdmTabs],
template: `<bpdm-tabs [items]="items" defaultValue="overview" fullWidth />`,
})
export class FullWidthTabs {
readonly items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
];
}Baseline
For the underline variant, baseline controls how far the track runs — full
(across the whole row, default) or content (only under the tabs).
Key metrics and recent activity for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
];
export function BaselineTabs() {
return <Tabs items={items} defaultValue="overview" baseline="content" />;
}import { Component } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'baseline-tabs',
imports: [BpdmTabs],
template: `<bpdm-tabs [items]="items" defaultValue="overview" baseline="content" />`,
})
export class BaselineTabs {
readonly items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
];
}Disabled tab
Set disabled on an item to make it non-selectable (arrow-key navigation skips it).
Key metrics for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings', disabled: true },
];
export function DisabledTabs() {
return <Tabs items={items} defaultValue="overview" />;
}import { Component } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'disabled-tabs',
imports: [BpdmTabs],
template: `<bpdm-tabs [items]="items" defaultValue="overview" />`,
})
export class DisabledTabs {
readonly items: TabItem[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings', disabled: true },
];
}Scrollable
When there are more tabs than fit the row, set scrollable and the tab row scrolls
horizontally instead of squeezing or wrapping. The scrollbar is hidden for a clean
bar, so chevron buttons appear at the edges — mouse users click them to scroll
(no trackpad or wheel needed). Trackpad/drag works too, and arrow-key navigation
scrolls the focused tab into view automatically.
Overview — settings and details for this section.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
'Overview', 'Activity', 'Deployments', 'Analytics', 'Members',
'Billing', 'Integrations', 'Security', 'Notifications', 'Advanced',
].map((label) => ({
value: label.toLowerCase(),
label,
content: <p>{label} — settings and details for this section.</p>,
}));
export function ScrollableTabs() {
return <Tabs items={items} defaultValue="overview" scrollable ariaLabel="Workspace sections" />;
}import { Component } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'scrollable-tabs',
imports: [BpdmTabs],
template: `<bpdm-tabs [items]="items" defaultValue="overview" scrollable ariaLabel="Workspace sections" />`,
})
export class ScrollableTabs {
readonly items: TabItem[] = [
'Overview', 'Activity', 'Deployments', 'Analytics', 'Members',
'Billing', 'Integrations', 'Security', 'Notifications', 'Advanced',
].map((label) => ({ value: label.toLowerCase(), label }));
}Orientation
orientation="vertical" stacks the tabs in a column beside their panels — handy for
settings-style layouts with many sections. It sets aria-orientation="vertical" on
the tablist and switches arrow-key navigation to Up/Down (horizontal tabs use
Left/Right). The default is horizontal.
Key metrics and recent activity for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview', content: <p>Key metrics and recent activity.</p> },
{ value: 'activity', label: 'Activity', content: <p>A feed of deploys, comments, and reviews.</p> },
{ value: 'settings', label: 'Settings', content: <p>Manage members, roles, and integrations.</p> },
];
export function VerticalTabs() {
return <Tabs items={items} defaultValue="overview" orientation="vertical" />;
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'vertical-tabs',
imports: [BpdmTabs],
templateUrl: './vertical.html',
})
export class VerticalTabs {
readonly overview = viewChild.required<TemplateRef<unknown>>('overview');
readonly activity = viewChild.required<TemplateRef<unknown>>('activity');
readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
readonly items = computed<TabItem[]>(() => [
{ value: 'overview', label: 'Overview', content: this.overview() },
{ value: 'activity', label: 'Activity', content: this.activity() },
{ value: 'settings', label: 'Settings', content: this.settings() },
]);
}<bpdm-tabs [items]="items()" defaultValue="overview" orientation="vertical" />
<ng-template #overview><p>Key metrics and recent activity.</p></ng-template>
<ng-template #activity><p>A feed of deploys, comments, and reviews.</p></ng-template>
<ng-template #settings><p>Manage members, roles, and integrations.</p></ng-template>Manual activation
By default (activationMode="automatic") arrow keys move focus and select the
tab, so the panel changes as you arrow through. Set activationMode="manual" to
split the two: arrow keys only move focus (roving), and you press Enter or
Space to select. Prefer manual when a panel is expensive to render, so arrowing
past it doesn't mount work you don't need yet.
Key metrics and recent activity for your workspace.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview', content: <p>Key metrics and recent activity.</p> },
{ value: 'activity', label: 'Activity', content: <p>A feed of deploys, comments, and reviews.</p> },
{ value: 'settings', label: 'Settings', content: <p>Manage members, roles, and integrations.</p> },
];
export function ManualTabs() {
return <Tabs items={items} defaultValue="overview" activationMode="manual" />;
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'manual-tabs',
imports: [BpdmTabs],
template: `
<bpdm-tabs [items]="items()" defaultValue="overview" activationMode="manual" />
<ng-template #overview><p>Key metrics and recent activity.</p></ng-template>
<ng-template #activity><p>A feed of deploys, comments, and reviews.</p></ng-template>
<ng-template #settings><p>Manage members, roles, and integrations.</p></ng-template>
`,
})
export class ManualTabs {
readonly overview = viewChild.required<TemplateRef<unknown>>('overview');
readonly activity = viewChild.required<TemplateRef<unknown>>('activity');
readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
readonly items = computed<TabItem[]>(() => [
{ value: 'overview', label: 'Overview', content: this.overview() },
{ value: 'activity', label: 'Activity', content: this.activity() },
{ value: 'settings', label: 'Settings', content: this.settings() },
]);
}Controlled value
Drive the active tab yourself with value + onValueChange (React) or two-way
[(value)] (Angular).
overviewKey metrics and recent activity for your workspace.
import { useState } from 'react';
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview', content: <p>Key metrics and recent activity.</p> },
{ value: 'activity', label: 'Activity', content: <p>A feed of deploys, comments, and reviews.</p> },
{ value: 'settings', label: 'Settings', content: <p>Manage members, roles, and integrations.</p> },
];
export function ControlledTabs() {
const [value, setValue] = useState('overview');
return <Tabs items={items} value={value} onValueChange={setValue} />;
}import { Component, signal, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'controlled-tabs',
imports: [BpdmTabs],
templateUrl: './tabs-controlled.html',
})
export class ControlledTabs {
readonly active = signal('overview');
readonly overview = viewChild.required<TemplateRef<unknown>>('overview');
readonly activity = viewChild.required<TemplateRef<unknown>>('activity');
readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
readonly items = computed<TabItem[]>(() => [
{ value: 'overview', label: 'Overview', content: this.overview() },
{ value: 'activity', label: 'Activity', content: this.activity() },
{ value: 'settings', label: 'Settings', content: this.settings() },
]);
}<bpdm-tabs [items]="items()" [(value)]="active" />
<ng-template #overview><p>Key metrics and recent activity.</p></ng-template>
<ng-template #activity><p>A feed of deploys, comments, and reviews.</p></ng-template>
<ng-template #settings><p>Manage members, roles, and integrations.</p></ng-template>Composition
Prefer to build the tree yourself? In React, import the parts —
TabsRoot / TabsList / TabsTrigger / TabsContent — and wire them up
instead of passing items. This is the escape hatch for custom layouts (e.g. a
toolbar between the list and the panels). Give the list an aria-label so screen
readers can name it. Angular is data-driven only — use items there.
Key metrics and recent activity for your workspace.
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@bpdm/ui/tabs';
export function ComposedTabs() {
return (
<TabsRoot defaultValue="overview">
<TabsList aria-label="Workspace sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview">Key metrics and recent activity.</TabsContent>
<TabsContent value="activity">A feed of deploys, comments, and reviews.</TabsContent>
<TabsContent value="settings">Manage members, roles, and integrations.</TabsContent>
</TabsRoot>
);
}Internationalization
- The tabs render no copy of their own — every tab
label, itscontent, and an optionaliconcomes from theitemsyou pass. So you localize at the data level: translate youritemsand the tabs render them as-is. There is nomessagesprop. - One translatable a11y string. Pass
ariaLabelto name the tablist for screen readers (announced as e.g. "Kontobereiche, tab list"). Translate it alongside your labels. - RTL-safe by construction: the tabs mirror automatically under
dir="rtl"— the visual order flips and arrow keys mirror too (Right moves to the previous tab, Left to the next), with no prop required. You usually don't setdiryourself; the component auto-detects the ambient direction. The German example below is left-to-right; for a right-to-left locale, render it inside adir="rtl"container (or inheritdirfrom the page). See the Internationalization guide.
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
// German (de-DE) tabs — localize the items you pass in
const items: TabItem[] = [
{ value: 'overview', label: 'Übersicht', content: <p>Kennzahlen und letzte Aktivitäten.</p> },
{ value: 'activity', label: 'Aktivität', content: <p>Ein Verlauf von Deployments, Kommentaren und Reviews.</p> },
{ value: 'settings', label: 'Einstellungen', content: <p>Mitglieder, Rollen und Integrationen verwalten.</p> },
];
export function GermanTabs() {
return <Tabs items={items} defaultValue="overview" ariaLabel="Kontobereiche" />;
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmTabs, type TabItem } from '@bpdm/ng';
@Component({
selector: 'german-tabs',
imports: [BpdmTabs],
templateUrl: './tabs-de.html',
})
export class GermanTabs {
readonly overview = viewChild.required<TemplateRef<unknown>>('overview');
readonly activity = viewChild.required<TemplateRef<unknown>>('activity');
readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
// German (de-DE) tabs — localize the items you pass in
readonly items = computed<TabItem[]>(() => [
{ value: 'overview', label: 'Übersicht', content: this.overview() },
{ value: 'activity', label: 'Aktivität', content: this.activity() },
{ value: 'settings', label: 'Einstellungen', content: this.settings() },
]);
}<bpdm-tabs [items]="items()" defaultValue="overview" ariaLabel="Kontobereiche" />
<ng-template #overview><p>Kennzahlen und letzte Aktivitäten.</p></ng-template>
<ng-template #activity><p>Ein Verlauf von Deployments, Kommentaren und Reviews.</p></ng-template>
<ng-template #settings><p>Mitglieder, Rollen und Integrationen verwalten.</p></ng-template>API
Tabs / <bpdm-tabs>
| Prop | Type | Default | Description |
|---|---|---|---|
items | TabItem[] | — | Required. The tabs. |
value / defaultValue / onValueChange | string | — | Active tab — controlled or uncontrolled. Angular: [(value)]. |
variant | underline | pill | underline | Indicator style. |
baseline | full | content | full | Underline track width (underline variant). |
orientation | horizontal | vertical | horizontal | Lay the tabs out in a row or a column; sets aria-orientation and the arrow-key axis. |
activationMode | automatic | manual | automatic | automatic selects on arrow; manual moves focus only and selects on Enter/Space. |
fullWidth | boolean | false | Stretch tabs to fill the row. |
scrollable | boolean | false | Scroll the tab row horizontally when the tabs overflow (many tabs); focus scrolls into view. |
ariaLabel | string | — | Accessible name for the tablist (screen readers). The one translatable a11y string. |
dir | ltr | rtl | (auto) | Arrow-key direction for horizontal tabs. Omit to auto-detect the ambient direction — you rarely need to set this. |
className / listClassName | string | — | Extra classes on the root / the tab list. |
Angular exposes the same inputs (ariaLabel, orientation, activationMode, dir);
the active tab is a two-way binding via [(value)], and a tab's content / icon
are <ng-template>s referenced from items.
TabItem
| Field | Type | Description |
|---|---|---|
value | string | Unique id for the tab. |
label | ReactNode (Angular string) | The tab's label. |
content | ReactNode (Angular TemplateRef) | The panel shown when active. |
icon | ReactNode (Angular TemplateRef) | Optional leading icon. |
disabled | boolean | Make the tab non-selectable. |
Composable parts (React)
| Part | Description |
|---|---|
TabsRoot | The state container — takes value / defaultValue / onValueChange (and orientation / activationMode / dir). |
TabsList | The role="tablist" wrapper — takes variant / baseline, and aria-label to name the list. |
TabsTrigger | A role="tab" — takes value, variant, and disabled. |
TabsContent | A role="tabpanel" matched to a trigger by value. |
Prefer composition? Import TabsRoot / TabsList / TabsTrigger / TabsContent
and build the tree yourself instead of passing items. Angular is data-driven only.
Accessibility
Both frameworks implement the same WAI-ARIA tabs pattern:
- Roles and wiring. The list is a
role="tablist", each tab arole="tab", and each panel arole="tabpanel". A tab and its panel are linked both ways —aria-controlsfrom tab to panel,aria-labelledbyfrom panel back to tab — and the active tab carriesaria-selected="true". - Roving tabindex. Only the active (or last-focused) tab is in the tab order; the
rest are
tabindex="-1". So Tab moves into and out of the tablist as a single stop, and arrow keys move between tabs inside it. - Keyboard. Arrow keys move between tabs — Left/Right for horizontal, Up/Down for vertical — and Home / End jump to the first / last tab. Navigation is auto-mirrored under RTL (Right goes to the previous tab). Disabled tabs are skipped.
- Automatic vs manual activation. With
activationMode="automatic"(default), arrowing selects as it moves focus; withmanual, arrowing only moves focus and you press Enter / Space to select. aria-orientation. The tablist advertises its orientation (horizontalorvertical) so assistive tech announces and navigates it correctly.- Named tablist. Pass
ariaLabelto give the tablist an accessible name (e.g. "Settings", announced as a tab list). - Focus and the panel. Focused tabs show a visible focus-visible ring, and the active panel is focusable so keyboard users land on the content after the tab.
- State is not colour-only — the selected tab is also bolder (and, in
pill, filled) alongsidearia-selected, so selection is conveyed without relying on hue. - Motion. Switching panels plays a subtle fade-in; it's brief and non-essential to understanding the change.
Card
A composable surface — media, header/title/description, content and footer; three variants, hover-lift and fully-clickable modes, in React (@bpdm/ui) and Angular (@bpdm/ng).
Stepper
A step-by-step flow — horizontal or vertical, optional linear gating, driven by a small hook; in React (@bpdm/ui) and Angular (@bpdm/ng).