Tabs
Tabs organize related content at the same level, one view at a time. M3 has two variants: primary tabs sit at the top of the content pane, under a top app bar, and secondary tabs subdivide one of those views. Tabs are for related content, not sequential steps. See the M3 tabs guidelines.
Usage
Start one tab with state: 'active'; the component does not pick one. change fires with the
tab's value whenever the selection changes.
import { createTabs } from 'mtrl';
const tabs = createTabs({
tabs: [
{ text: 'Flights', value: 'flights', state: 'active' },
{ text: 'Trips', value: 'trips' },
{ text: 'Explore', value: 'explore' },
],
});
tabs.on('change', ({ value }) => showPanel(value));
document.body.append(tabs.element);
<m-tabs value="flights">
<m-tab value="flights">Flights</m-tab>
<m-tab value="trips">Trips</m-tab>
<m-tab value="explore">Explore</m-tab>
</m-tabs>
<script type="module">
const tabs = document.querySelector('m-tabs');
tabs.addEventListener('change', (event) => showPanel(event.detail.value));
</script>
import { useState } from 'react';
import { Tabs, Tab } from 'mtrl/react';
import { showPanel } from './app';
export function Example() {
const [value, setValue] = useState("flights");
return (
<Tabs value={value} onChange={(event) => { setValue(event.detail.value); showPanel(event.detail.value); }}>
<Tab value="flights">Flights</Tab>
<Tab value="trips">Trips</Tab>
<Tab value="explore">Explore</Tab>
</Tabs>
);
}
<script setup lang="ts">
import { ref } from 'vue';
import { MTabs, MTab } from 'mtrl/vue';
import { showPanel } from './app';
const value = ref("flights");
</script>
<template>
<MTabs v-model="value" @change="showPanel($event.detail.value)">
<MTab value="flights">Flights</MTab>
<MTab value="trips">Trips</MTab>
<MTab value="explore">Explore</MTab>
</MTabs>
</template>
<script lang="ts">
import { Tabs, Tab } from 'mtrl/svelte';
import { showPanel } from './app';
let value = $state("flights");
</script>
<Tabs bind:value onchange={(event) => showPanel(event.detail.value)}>
<Tab value="flights">Flights</Tab>
<Tab value="trips">Trips</Tab>
<Tab value="explore">Explore</Tab>
</Tabs>
import { createSignal } from 'solid-js';
import { Tabs, Tab } from 'mtrl/solid';
import { showPanel } from './app';
export function Example() {
const [value, setValue] = createSignal("flights");
return (
<Tabs value={value()} onChange={(event) => { setValue(event.detail.value); showPanel(event.detail.value); }}>
<Tab value="flights">Flights</Tab>
<Tab value="trips">Trips</Tab>
<Tab value="explore">Explore</Tab>
</Tabs>
);
}
Examples
Icons and a badge
With a label, the icon sits above it and the row is 64dp tall. A tab with an icon and no
label needs ariaLabel. A badge is a count or a short text, four characters at most.
import { createTabs } from 'mtrl';
const tabs = createTabs({
tabs: [
{ text: 'Video', value: 'video', icon: videoIcon, state: 'active' },
{ text: 'Photos', value: 'photos', icon: photoIcon, badge: 3 },
{ text: 'Audio', value: 'audio', icon: audioIcon },
],
});
document.body.append(tabs.element);
<m-tabs value="video">
<m-tab value="video">Video</m-tab>
<m-tab value="photos" badge="3">Photos</m-tab>
<m-tab value="audio">Audio</m-tab>
</m-tabs>
<script type="module">
const tabs = document.querySelector('m-tabs');
tabs.querySelector('m-tab[value="video"]').setAttribute('icon', videoIcon);
tabs.querySelector('m-tab[value="photos"]').setAttribute('icon', photoIcon);
tabs.querySelector('m-tab[value="audio"]').setAttribute('icon', audioIcon);
</script>
import { useState } from 'react';
import { Tabs, Tab } from 'mtrl/react';
import { videoIcon, photoIcon, audioIcon } from './app';
export function Example() {
const [value, setValue] = useState("video");
return (
<Tabs value={value} onChange={(event) => setValue(event.detail.value)}>
<Tab value="video" icon={videoIcon}>Video</Tab>
<Tab value="photos" icon={photoIcon} badge="3">Photos</Tab>
<Tab value="audio" icon={audioIcon}>Audio</Tab>
</Tabs>
);
}
<script setup lang="ts">
import { ref } from 'vue';
import { MTabs, MTab } from 'mtrl/vue';
import { videoIcon, photoIcon, audioIcon } from './app';
const value = ref("video");
</script>
<template>
<MTabs v-model="value">
<MTab value="video" :icon="videoIcon">Video</MTab>
<MTab value="photos" :icon="photoIcon" badge="3">Photos</MTab>
<MTab value="audio" :icon="audioIcon">Audio</MTab>
</MTabs>
</template>
<script lang="ts">
import { Tabs, Tab } from 'mtrl/svelte';
import { videoIcon, photoIcon, audioIcon } from './app';
let value = $state("video");
</script>
<Tabs bind:value>
<Tab value="video" icon={videoIcon}>Video</Tab>
<Tab value="photos" icon={photoIcon} badge="3">Photos</Tab>
<Tab value="audio" icon={audioIcon}>Audio</Tab>
</Tabs>
import { createSignal } from 'solid-js';
import { Tabs, Tab } from 'mtrl/solid';
import { videoIcon, photoIcon, audioIcon } from './app';
export function Example() {
const [value, setValue] = createSignal("video");
return (
<Tabs value={value()} onChange={(event) => setValue(event.detail.value)}>
<Tab value="video" icon={videoIcon}>Video</Tab>
<Tab value="photos" icon={photoIcon} badge="3">Photos</Tab>
<Tab value="audio" icon={audioIcon}>Audio</Tab>
</Tabs>
);
}
Secondary
import { createTabs } from 'mtrl';
const tabs = createTabs({
variant: 'secondary',
tabs: [
{ text: 'Overview', value: 'overview', state: 'active' },
{ text: 'Specifications', value: 'specs' },
],
});
document.body.append(tabs.element);
<m-tabs value="overview" variant="secondary">
<m-tab value="overview">Overview</m-tab>
<m-tab value="specs">Specifications</m-tab>
</m-tabs>
import { useState } from 'react';
import { Tabs, Tab } from 'mtrl/react';
export function Example() {
const [value, setValue] = useState("overview");
return (
<Tabs value={value} onChange={(event) => setValue(event.detail.value)} variant="secondary">
<Tab value="overview">Overview</Tab>
<Tab value="specs">Specifications</Tab>
</Tabs>
);
}
<script setup lang="ts">
import { ref } from 'vue';
import { MTabs, MTab } from 'mtrl/vue';
const value = ref("overview");
</script>
<template>
<MTabs v-model="value" variant="secondary">
<MTab value="overview">Overview</MTab>
<MTab value="specs">Specifications</MTab>
</MTabs>
</template>
<script lang="ts">
import { Tabs, Tab } from 'mtrl/svelte';
let value = $state("overview");
</script>
<Tabs bind:value variant="secondary">
<Tab value="overview">Overview</Tab>
<Tab value="specs">Specifications</Tab>
</Tabs>
import { createSignal } from 'solid-js';
import { Tabs, Tab } from 'mtrl/solid';
export function Example() {
const [value, setValue] = createSignal("overview");
return (
<Tabs value={value()} onChange={(event) => setValue(event.detail.value)} variant="secondary">
<Tab value="overview">Overview</Tab>
<Tab value="specs">Specifications</Tab>
</Tabs>
);
}
Panels
Give each panel role="tabpanel", the id tabpanel-<groupId>-<value> and
aria-labelledby="tab-<groupId>-<value>". The tabs point at their panels with
aria-controls, and show and hide them as the selection changes. Pin groupId in the
factory's options, or the id is allocated.
<div role="tabpanel" id="tabpanel-travel-flights" aria-labelledby="tab-travel-flights">…</div>
<div role="tabpanel" id="tabpanel-travel-trips" aria-labelledby="tab-travel-trips" hidden>…</div>
The factory's row scrolls by default; scrollable: false divides it evenly between the tabs
(fixed tabs). setupResponsiveBehavior(tabs, { smallScreen: { layout: 'icon-only' } }), from
mtrl/components/tabs, shows tabs with an icon and a label as icons only below 600px, the
label kept as their name. The web component takes the tabs, variant and the selection.
API
Options
| Option | Type | Default | Description |
|---|---|---|---|
tabs |
TabConfig[] |
[] |
The tabs to build, in order |
variant |
'primary' | 'secondary' |
'primary' |
Primary or secondary tabs |
scrollable |
boolean |
true |
A horizontally scrolling row, 52dp in from both edges. false divides the row evenly between the tabs (fixed tabs) |
showDivider |
boolean |
true |
The 1dp divider along the bottom of the row |
autoActivate |
boolean |
false |
Whether an arrow key also selects the tab it moves to. By default it only moves focus, and Space or Enter selects |
indicator |
IndicatorConfig |
{} |
The active indicator (below) |
groupId |
string |
allocated | The id every tab's id is built from, tab-<groupId>-<value>. Pin it when ids must survive a re-render |
on |
{ change } |
undefined |
Event handlers registered at creation, as with on() |
class |
string |
undefined |
Additional CSS classes on the row |
prefix |
string |
'mtrl' |
Prefix for CSS class names |
Tab options
Each entry of tabs, and the argument to addTab().
| Option | Type | Default | Description |
|---|---|---|---|
text |
string |
undefined |
The label |
icon |
string |
undefined |
Icon as SVG markup. With a label, the icon sits above it and the row is 64dp tall |
ariaLabel |
string |
undefined |
The accessible name of a tab with an icon and no label, which otherwise has none |
value |
string |
undefined |
Identifies the tab to change, setActiveTab() and its panel |
state |
'active' | 'inactive' |
'inactive' |
Whether the tab starts active |
disabled |
boolean |
false |
Whether the tab starts disabled |
badge |
string | number |
undefined |
Badge content; keep it to four characters, including a "+" |
badgeConfig |
object |
undefined |
Options for the badge: variant, color, size, position, max |
ripple |
boolean |
true |
Whether pressing the tab shows the ripple |
class |
string |
undefined |
Additional CSS classes on the tab |
Indicator options
| Option | Type | Default | Description |
|---|---|---|---|
widthStrategy |
'auto' | 'content' | 'dynamic' | 'fixed' |
'auto' |
auto: the label's width inset 2dp on each side (24dp at least) for primary tabs, the whole tab for secondary ones |
height |
number |
3 primary, 2 secondary | Height in pixels |
color |
string |
theme primary |
The indicator's color |
visible |
boolean |
true |
Whether it starts shown; getIndicator().hide() and show() change it later |
fixedWidth |
number |
40 |
Width for the fixed strategy |
animationDuration |
number |
spring | Replaces Material's default spatial spring with a fixed duration in milliseconds |
animationTiming |
string |
spring | An easing to use with animationDuration |
Methods
Tabs
| Method | Returns | Description |
|---|---|---|
addTab(config) |
TabComponent |
Builds a tab and appends it |
add(tab) |
TabsComponent |
Appends a tab built with createTab |
removeTab(tabOrValue) |
TabsComponent |
Removes and destroys a tab |
getTabs() |
TabComponent[] |
The tabs, in order |
getActiveTab() |
TabComponent | null |
The active tab |
setActiveTab(tabOrValue) |
TabsComponent |
Selects a tab, updates its panels and emits change. An unknown value clears the selection |
getIndicator() |
TabIndicator |
The indicator: show(), hide(), setColor(), update() |
on(event, handler) / off(event, handler) |
TabsComponent |
Events: change |
destroy() |
void |
Destroys every tab and the row |
Tab
| Method | Returns | Description |
|---|---|---|
getValue() / setValue(value) |
string / TabComponent |
The tab's value |
isActive() |
boolean |
Whether the tab is selected |
setText(text) / getText() |
TabComponent / string |
The label |
setIcon(icon) / getIcon() |
TabComponent / string |
The icon |
setBadge(content) / getBadge() / showBadge() / hideBadge() |
TabComponent / string |
The badge |
enable() / disable() |
TabComponent |
Disabled state |
on(event, handler) / off(event, handler) |
TabComponent |
Events: click, focus, blur |
destroy() |
void |
Removes the tab |
Events
| Event | Payload | Description |
|---|---|---|
change |
{ tab, value } |
The selected tab changed, by pointer, keyboard or setActiveTab(). tab and value are null when an unknown value cleared the selection |
The web component's change carries { value }.
Accessibility
- The row is a
tablist, each tab atabwitharia-selected, and a panel supplied as above atabpanellinked byaria-controls. Name the row witharia-labelwhen nothing else on the page does. - The row is one Tab stop, on the selected tab. The arrows move focus between tabs, following
the reading direction;
HomeandEndgo to the ends; disabled tabs are skipped. SpaceorEnterselects the focused tab; withautoActivate, every arrow press selects.- The 3dp focus ring is drawn inside the tab, so the row's edge never clips it.
Styling
.mtrl-tabs { } /* the row */
.mtrl-tabs--primary, .mtrl-tabs--secondary, .mtrl-tabs--scrollable { }
.mtrl-tabs__scroll { } /* the scrolling container */
.mtrl-tabs__indicator, .mtrl-tabs__divider { }
.mtrl-tabs--responsive-small { } /* below the small breakpoint */
.mtrl-tab { } /* one tab, a button */
.mtrl-tab--active { }
.mtrl-tab--text-only, .mtrl-tab--icon-only, .mtrl-tab--icon-and-text { }
.mtrl-tab-panel { }
Measurements
From the M3 tabs specs, then Compose's PrimaryNavigationTabTokens and
SecondaryNavigationTabTokens:
| Attribute | Value |
|---|---|
| Height | 48dp with a label or an icon, 64dp with both |
| Label | Title Small; primary (primary) or on-surface (secondary) when active, on-surface-variant when inactive |
| Icon | 24dp |
| Tab padding | 16dp on each side |
| Primary indicator | 3dp, primary, top corners 3dp, the label's width inset 2dp on each side, 24dp at least |
| Secondary indicator | 2dp, primary, the tab's full width |
| Divider | 1dp outline-variant, inside the row's height |
| Scrollable edge | 52dp before the first tab and after the last |
| States | An inactive tab turns on-surface on hover, focus and press, over an on-surface layer; a press on a primary tab is primary, drawn by the ripple |
| Motion | The indicator moves on the default spatial spring |