Skip to main content

Tabs

Controlled tab navigation with three variants and keyboard support.

Quick start
import { Tabs } from "mates-ui";

Tabs({
  tabs: [{ id: "a", label: "Tab A" }],
  activeId: "a",
  onChange: () => {},
});

Line (default)

import { html, atom, renderApp } from 'mates';
import { Tabs } from 'mates-ui';
import { BlockIcon, HomeIcon, InfoIcon, SettingsIcon, TimelineIcon } from 'mates-icons';

const TABS = [
{ id: "overview", label: "Overview", icon: HomeIcon() },
{ id: "details", label: "Details", icon: InfoIcon() },
{ id: "activity", label: "Activity", icon: TimelineIcon(), badge: 12 },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "disabled", label: "Disabled", icon: BlockIcon(), disabled: true },
];
const PANELS = [
{ id: "overview", content: html`<p style="color:var(--md-color-text-muted)">Overview panel content.</p>` },
{ id: "details", content: html`<p style="color:var(--md-color-text-muted)">Detailed information.</p>` },
{ id: "activity", content: html`<p style="color:var(--md-color-text-muted)">Recent activity — 12 unread events.</p>` },
{ id: "settings", content: html`<p style="color:var(--md-color-text-muted)">Configuration options.</p>` },
{ id: "disabled", content: html`` },
];

const App = () => {
const active = atom("overview");
return () => html`
<div style="width:100%;max-width:36rem;">
${Tabs({ tabs: TABS, activeId: active(), variant: "line", onChange: (id) => active.set(id), panels: PANELS })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

Filled

import { html, atom, renderApp } from 'mates';
import { Tabs } from 'mates-ui';
import { BlockIcon, HomeIcon, InfoIcon, SettingsIcon, TimelineIcon } from 'mates-icons';

const TABS = [
{ id: "overview", label: "Overview", icon: HomeIcon() },
{ id: "details", label: "Details", icon: InfoIcon() },
{ id: "activity", label: "Activity", icon: TimelineIcon(), badge: 12 },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "disabled", label: "Disabled", icon: BlockIcon(), disabled: true },
];
const PANELS = [
{ id: "overview", content: html`<p style="color:var(--md-color-text-muted)">Overview panel content.</p>` },
{ id: "details", content: html`<p style="color:var(--md-color-text-muted)">Detailed information.</p>` },
{ id: "activity", content: html`<p style="color:var(--md-color-text-muted)">Recent activity feed.</p>` },
{ id: "settings", content: html`<p style="color:var(--md-color-text-muted)">Configuration options.</p>` },
{ id: "disabled", content: html`` },
];

const App = () => {
const active = atom("details");
return () => html`
<div style="width:100%;max-width:36rem;">
${Tabs({ tabs: TABS, activeId: active(), variant: "filled", onChange: (id) => active.set(id), panels: PANELS })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

Pill

import { html, atom, renderApp } from 'mates';
import { Tabs } from 'mates-ui';
import { BlockIcon, HomeIcon, InfoIcon, SettingsIcon, TimelineIcon } from 'mates-icons';

const TABS = [
{ id: "overview", label: "Overview", icon: HomeIcon() },
{ id: "details", label: "Details", icon: InfoIcon() },
{ id: "activity", label: "Activity", icon: TimelineIcon(), badge: 12 },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "disabled", label: "Disabled", icon: BlockIcon(), disabled: true },
];
const PANELS = [
{ id: "overview", content: html`<p style="color:var(--md-color-text-muted)">Overview panel content.</p>` },
{ id: "details", content: html`<p style="color:var(--md-color-text-muted)">Detailed information.</p>` },
{ id: "activity", content: html`<p style="color:var(--md-color-text-muted)">Recent activity feed.</p>` },
{ id: "settings", content: html`<p style="color:var(--md-color-text-muted)">Configuration options.</p>` },
{ id: "disabled", content: html`` },
];

const App = () => {
const active = atom("overview");
return () => html`
<div style="width:100%;max-width:36rem;">
${Tabs({ tabs: TABS, activeId: active(), variant: "pill", onChange: (id) => active.set(id), panels: PANELS })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

Sizes — sm / md / lg

import { html, atom, renderApp } from 'mates';
import { Tabs } from 'mates-ui';
import { BlockIcon, HomeIcon, InfoIcon, SettingsIcon, TimelineIcon } from 'mates-icons';

const TABS = [
{ id: "overview", label: "Overview", icon: HomeIcon() },
{ id: "details", label: "Details", icon: InfoIcon() },
{ id: "activity", label: "Activity", icon: TimelineIcon() },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "disabled", label: "Disabled", icon: BlockIcon(), disabled: true },
];

const App = () => {
const sm = atom("overview");
const lg = atom("overview");
return () => html`
<x-col gap="var(--md-space-5)" style="width:100%;max-width:36rem;">
${Tabs({ tabs: TABS, activeId: sm(), size: "sm", variant: "line", onChange: (id) => sm.set(id) })}
${Tabs({ tabs: TABS, activeId: lg(), size: "lg", variant: "pill", onChange: (id) => lg.set(id) })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

MD3 Ripple (enableRipple)

import { html, atom, renderApp } from 'mates';
import { Tabs } from 'mates-ui';
import { BlockIcon, HomeIcon, InfoIcon, SettingsIcon, TimelineIcon } from 'mates-icons';

const TABS = [
{ id: "overview", label: "Overview", icon: HomeIcon() },
{ id: "details", label: "Details", icon: InfoIcon() },
{ id: "activity", label: "Activity", icon: TimelineIcon() },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "disabled", label: "Disabled", icon: BlockIcon(), disabled: true },
];

const App = () => {
const line = atom("overview");
const pill = atom("overview");
return () => html`
<x-col gap="var(--md-space-5)" style="width:100%;max-width:36rem;">
${Tabs({ tabs: TABS, activeId: line(), variant: "line", enableRipple: true, onChange: (id) => line.set(id) })}
${Tabs({ tabs: TABS, activeId: pill(), variant: "pill", enableRipple: true, onChange: (id) => pill.set(id) })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

OverflowingTabs — line variant (resize window to collapse)

import { html, atom, renderApp } from 'mates';
import { OverflowingTabs } from 'mates-ui';
import { AnalyticsIcon, BarChartIcon, CreditCardIcon, DashboardIcon, DescriptionIcon, ExtensionIcon, GroupIcon, HelpIcon, ReceiptLongIcon, SettingsIcon, ShieldIcon } from 'mates-icons';

const TABS = [
{ id: "dashboard", label: "Dashboard", icon: DashboardIcon() },
{ id: "analytics", label: "Analytics", icon: AnalyticsIcon() },
{ id: "reports", label: "Reports", icon: DescriptionIcon(), badge: 3 },
{ id: "users", label: "Users", icon: GroupIcon() },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "billing", label: "Billing", icon: CreditCardIcon() },
{ id: "integrations", label: "Integrations", icon: ExtensionIcon() },
{ id: "security", label: "Security", icon: ShieldIcon() },
{ id: "logs", label: "Logs", icon: ReceiptLongIcon() },
{ id: "help", label: "Help", icon: HelpIcon() },
];

const App = () => {
const active = atom("dashboard");
return () => html`
<div style="width:100%;max-width:40rem;">
${OverflowingTabs({ tabs: TABS, activeId: active(), variant: "line", onChange: (id) => active.set(id), moreLabel: "More" })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

OverflowingTabs — filled variant

import { html, atom, renderApp } from 'mates';
import { OverflowingTabs } from 'mates-ui';
import { AnalyticsIcon, BarChartIcon, CreditCardIcon, DashboardIcon, DescriptionIcon, ExtensionIcon, GroupIcon, HelpIcon, ReceiptLongIcon, SettingsIcon, ShieldIcon } from 'mates-icons';

const TABS = [
{ id: "dashboard", label: "Dashboard", icon: DashboardIcon() },
{ id: "analytics", label: "Analytics", icon: AnalyticsIcon() },
{ id: "reports", label: "Reports", icon: DescriptionIcon(), badge: 3 },
{ id: "users", label: "Users", icon: GroupIcon() },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "billing", label: "Billing", icon: CreditCardIcon() },
{ id: "integrations", label: "Integrations", icon: ExtensionIcon() },
{ id: "security", label: "Security", icon: ShieldIcon() },
{ id: "logs", label: "Logs", icon: ReceiptLongIcon() },
{ id: "help", label: "Help", icon: HelpIcon() },
];

const App = () => {
const active = atom("dashboard");
return () => html`
<div style="width:100%;max-width:40rem;">
${OverflowingTabs({ tabs: TABS, activeId: active(), variant: "filled", onChange: (id) => active.set(id), moreLabel: "More" })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

OverflowingTabs — constrained to 380 px (always overflows)

import { html, atom, renderApp } from 'mates';
import { OverflowingTabs } from 'mates-ui';
import { AnalyticsIcon, BarChartIcon, CreditCardIcon, DashboardIcon, DescriptionIcon, ExtensionIcon, GroupIcon, HelpIcon, ReceiptLongIcon, SettingsIcon, ShieldIcon } from 'mates-icons';

const TABS = [
{ id: "dashboard", label: "Dashboard", icon: DashboardIcon() },
{ id: "analytics", label: "Analytics", icon: AnalyticsIcon() },
{ id: "reports", label: "Reports", icon: DescriptionIcon(), badge: 3 },
{ id: "users", label: "Users", icon: GroupIcon() },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "billing", label: "Billing", icon: CreditCardIcon() },
{ id: "integrations", label: "Integrations", icon: ExtensionIcon() },
{ id: "security", label: "Security", icon: ShieldIcon() },
{ id: "logs", label: "Logs", icon: ReceiptLongIcon() },
{ id: "help", label: "Help", icon: HelpIcon() },
];

const App = () => {
const active = atom("dashboard");
return () => html`
<div style="max-width:380px;width:100%;">
${OverflowingTabs({ tabs: TABS, activeId: active(), variant: "line", onChange: (id) => active.set(id), moreLabel: "More tabs" })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

OverflowingTabs — MD3 Ripple

import { html, atom, renderApp } from 'mates';
import { OverflowingTabs } from 'mates-ui';
import { AnalyticsIcon, BarChartIcon, CreditCardIcon, DashboardIcon, DescriptionIcon, ExtensionIcon, GroupIcon, HelpIcon, ReceiptLongIcon, SettingsIcon, ShieldIcon } from 'mates-icons';

const TABS = [
{ id: "dashboard", label: "Dashboard", icon: DashboardIcon() },
{ id: "analytics", label: "Analytics", icon: AnalyticsIcon() },
{ id: "reports", label: "Reports", icon: DescriptionIcon(), badge: 3 },
{ id: "users", label: "Users", icon: GroupIcon() },
{ id: "settings", label: "Settings", icon: SettingsIcon() },
{ id: "billing", label: "Billing", icon: CreditCardIcon() },
{ id: "integrations", label: "Integrations", icon: ExtensionIcon() },
{ id: "security", label: "Security", icon: ShieldIcon() },
{ id: "logs", label: "Logs", icon: ReceiptLongIcon() },
{ id: "help", label: "Help", icon: HelpIcon() },
];

const App = () => {
const active = atom("dashboard");
return () => html`
<div style="width:100%;max-width:40rem;">
${OverflowingTabs({ tabs: TABS, activeId: active(), variant: "line", enableRipple: true, onChange: (id) => active.set(id), moreLabel: "More" })}
</div>
`;
};

renderApp(App, document.getElementById('app'));

Tabs

import { Tabs } from "mates-ui";
Prop Type Default Description
activeId* string Id of the currently active tab.
attr AttrMap Extra HTML attributes forwarded to internal elements.
classes string | string[] Extra root classes — joined and appended after built-in classes.
data DataAttrMap Root `data-*` map (`testId` → `data-testid`). `testId` prop / `attr` win over `data`.
enableBloom boolean true When true and enableRipple is false, applies the soft bloom press effect.
enableRipple boolean true When true, replaces the default soft bloom press effect with a Material Design 3-style ripple — a sharp-edged circle that grows from the contact point, with hover/focus state-layer and keyboard (Enter / Space) support.
id string Root `id` shorthand (`attr.id` wins). Form native-control `id` props are unchanged.
on OnEventMap DOM event handlers on the root element.
onChange* (id: string) => void Called when the user activates a tab.
panels TabPanel[] Optional array of panels rendered below the strip.
segmentedBackgroundType "filled" | "standard" | "none" "none" Background style of the active tab in segmented variant.
segmentedShape "rounded" | "pill" | "squared" "squared" Shape of the active-tab background block. Only used when variant === 'segmented'.
size "sm" | "md" | "lg" "md" Height / font-size scale.
style StyleMap Inline CSS on the root element.
style StyleMap Root inline CSS (`style` prop; `style` wins if both set).
tabs* TabItem[] Ordered array of tab descriptors.
testId string Overrides the default data-testid attribute.
variant "line" | "filled" | "pill" "line" Visual style of the tab strip.

TabItem

Prop Type Default Description
badge string | number Badge count or label shown as a small indicator on the tab.
disabled boolean Prevents the tab from being activated.
icon IconInput Icon before label (Material name, SVG, URL, or lit-html).
id* string Unique identifier — matched against activeId and panel ids.
isLoading boolean Shows a mini loading spinner inside the tab.
label* string Visible tab label.

TabPanel

Prop Type Default Description
content* TemplateResult | (() => TemplateResult) Content rendered inside the panel.
id* string Must match a TabItem id — controls which panel is shown.

CSS tokens

Token Role
--md-color-border Track colour beneath the tab strip in line variant.
--md-color-on-primary Text colour of the active tab in filled variant.
--md-color-primary Active indicator underline (line) or background (filled/pill).
--md-color-surface-raised Background of inactive tabs in filled variant.

Caveats

  • Always provide matching `TabPanel` ids — mismatched ids silently render no panel.
  • `panels` is optional — omit when you handle content rendering yourself.
  • Keyboard: Arrow keys move between tabs, Enter/Space activates.