/Documentation

Tabs

PublishedUpdated

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.

Vanilla
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);
Web Components
<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>
React
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>
  );
}
Vue
<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>
Svelte
<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>
SolidJS
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.

Vanilla
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);
Web Components
<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>
React
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>
  );
}
Vue
<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>
Svelte
<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>
SolidJS
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

Vanilla
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);
Web Components
<m-tabs value="overview" variant="secondary">
  <m-tab value="overview">Overview</m-tab>
  <m-tab value="specs">Specifications</m-tab>
</m-tabs>
React
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>
  );
}
Vue
<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>
Svelte
<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>
SolidJS
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 a tab with aria-selected, and a panel supplied as above a tabpanel linked by aria-controls. Name the row with aria-label when 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; Home and End go to the ends; disabled tabs are skipped.
  • Space or Enter selects the focused tab; with autoActivate, 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