Skip to content

Navigation

Tabs

Tabs switch between groups of content at the same level: primary or secondary, fixed or scrollable.

Examples

Primary tabs

Icons above the labels, and a badge. Activation is manual, as in Compose: the arrow keys, Home and End move the focus, and Enter, Space or a click selects. Tab moves into the panel.

The live demo runs with JavaScript. Its code is below.

React HTML
import { TabPanel, Tabs, type TabItem } from '@kyralabs/m3exp-web/react';

const ITEMS: TabItem[] = [
  { key: 'flights', label: 'Flights', icon: 'flight' },
  { key: 'trips', label: 'Trips', icon: 'luggage', badge: 3 },
  { key: 'explore', label: 'Explore', icon: 'explore' },
];

export default function Primary() {
  return (
    <div style={{ inlineSize: '100%' }}>
      <Tabs aria-label="Travel" items={ITEMS} defaultSelectedKey="flights">
        <TabPanel id="flights">No upcoming flights.</TabPanel>
        <TabPanel id="trips">Three trips planned.</TabPanel>
        <TabPanel id="explore">Places to go next.</TabPanel>
      </Tabs>
    </div>
  );
}
<div style="inline-size: 100%">
  <m3e-tabs label="Travel" selected="flights">
    <m3e-tab value="flights" icon="flight">Flights</m3e-tab>
    <m3e-tab value="trips" icon="luggage" badge="3">Trips</m3e-tab>
    <m3e-tab value="explore" icon="explore">Explore</m3e-tab>
    <m3e-tab-panel value="flights">No upcoming flights.</m3e-tab-panel>
    <m3e-tab-panel value="trips">Three trips planned.</m3e-tab-panel>
    <m3e-tab-panel value="explore">Places to go next.</m3e-tab-panel>
  </m3e-tabs>
</div>

Run the HTML version (custom elements, no framework).

Secondary tabs

For a second level of content. A disabled tab stays focusable (aria-disabled), as APG recommends.

The live demo runs with JavaScript. Its code is below.

React HTML
import { Tab, TabPanel, Tabs } from '@kyralabs/m3exp-web/react';

export default function Secondary() {
  return (
    <div style={{ inlineSize: '100%' }}>
      <Tabs aria-label="Trip details" variant="secondary" defaultSelectedKey="overview">
        <Tab id="overview">Overview</Tab>
        <Tab id="itinerary">Itinerary</Tab>
        <Tab id="tickets" isDisabled>
          Tickets
        </Tab>
        <TabPanel id="overview">Five days in the mountains.</TabPanel>
        <TabPanel id="itinerary">Day 1: arrive and settle in.</TabPanel>
        <TabPanel id="tickets">Tickets go on sale soon.</TabPanel>
      </Tabs>
    </div>
  );
}
<div style="inline-size: 100%">
  <m3e-tabs label="Trip details" variant="secondary" selected="overview">
    <m3e-tab value="overview">Overview</m3e-tab>
    <m3e-tab value="itinerary">Itinerary</m3e-tab>
    <m3e-tab value="tickets" disabled>Tickets</m3e-tab>
    <m3e-tab-panel value="overview">Five days in the mountains.</m3e-tab-panel>
    <m3e-tab-panel value="itinerary">Day 1: arrive and settle in.</m3e-tab-panel>
    <m3e-tab-panel value="tickets">Tickets go on sale soon.</m3e-tab-panel>
  </m3e-tabs>
</div>

Run the HTML version (custom elements, no framework).

Scrollable tabs

With leading icons. The row scrolls the selected tab into view.

The live demo runs with JavaScript. Its code is below.

React HTML
import { TabPanel, Tabs, type TabItem } from '@kyralabs/m3exp-web/react';

const ITEMS: TabItem[] = [
  { key: 'all', label: 'All', icon: 'apps' },
  { key: 'photos', label: 'Photos', icon: 'photo' },
  { key: 'videos', label: 'Videos', icon: 'movie' },
  { key: 'music', label: 'Music', icon: 'music_note' },
  { key: 'books', label: 'Books', icon: 'menu_book' },
  { key: 'podcasts', label: 'Podcasts', icon: 'podcasts' },
  { key: 'documents', label: 'Documents', icon: 'description' },
];

export default function Scrollable() {
  return (
    <div style={{ inlineSize: '100%', maxInlineSize: 'var(--m3e-sys-window-width-medium)' }}>
      <Tabs
        aria-label="Library"
        scrollable
        iconPosition="leading"
        items={ITEMS}
        defaultSelectedKey="photos"
      >
        {ITEMS.map((item) => (
          <TabPanel key={item.key} id={item.key}>
            {item.label} in your library.
          </TabPanel>
        ))}
      </Tabs>
    </div>
  );
}
<div style="inline-size: 100%; max-inline-size: var(--m3e-sys-window-width-medium)">
  <m3e-tabs label="Library" scrollable icon-position="leading" selected="photos">
    <m3e-tab value="all" icon="apps">All</m3e-tab>
    <m3e-tab value="photos" icon="photo">Photos</m3e-tab>
    <m3e-tab value="videos" icon="movie">Videos</m3e-tab>
    <m3e-tab value="music" icon="music_note">Music</m3e-tab>
    <m3e-tab value="books" icon="menu_book">Books</m3e-tab>
    <m3e-tab value="podcasts" icon="podcasts">Podcasts</m3e-tab>
    <m3e-tab value="documents" icon="description">Documents</m3e-tab>
    <m3e-tab-panel value="all">All in your library.</m3e-tab-panel>
    <m3e-tab-panel value="photos">Photos in your library.</m3e-tab-panel>
    <m3e-tab-panel value="videos">Videos in your library.</m3e-tab-panel>
    <m3e-tab-panel value="music">Music in your library.</m3e-tab-panel>
    <m3e-tab-panel value="books">Books in your library.</m3e-tab-panel>
    <m3e-tab-panel value="podcasts">Podcasts in your library.</m3e-tab-panel>
    <m3e-tab-panel value="documents">Documents in your library.</m3e-tab-panel>
  </m3e-tabs>
</div>

Run the HTML version (custom elements, no framework).

API

React

<Tabs>

Tabs (COMPONENT_SPEC §17; Compose PrimaryTabRow / SecondaryTabRow / PrimaryScrollableTabRow / SecondaryScrollableTabRow with Tab / LeadingIconTab): the tab row (role="tablist", named by aria-label) followed by its <TabPanel> children, wired as APG tabs. m3e-elements' TabsLayout (the code <m3e-tabs> runs) moves the focus with the arrow keys (manual activation: a press, Enter or Space selects), slides the indicator on Compose's spring and scrolls a scrollable row to the selected tab. Other props go to the .m3e-tabs root.

Prop Type Default Description
variant 'primary' | 'secondary' primary (default, Compose PrimaryTabRow) or secondary (SecondaryTabRow).
scrollable boolean Scrollable row (Compose *ScrollableTabRow): tabs as wide as their content, scrolled.
iconPosition 'top' | 'leading' top (default): icons above labels; leading: before them (Compose LeadingIconTab).
items readonly TabItem[] The tabs (or <Tab> children).
selectedKey string | null | undefined The selected tab (controlled).
defaultSelectedKey string | null | undefined The initially selected tab (uncontrolled). Default: the first enabled tab.
id string | undefined Prefix of the tab and panel ids (default: a generated one). Not rendered on the root.
children ReactNode <Tab> declarations and <TabPanel>s (rendered after the row, in order).
onSelectionChange ((key: string) => void) | undefined Called when another tab is pressed (or activated with Enter / Space).

Also accepts HTML attributes not listed here.

<Tab>

Declares one tab as a child of <Tabs> (instead of items). Renders nothing itself: <Tabs> renders the .m3e-tab contract.

Prop Type Default Description
id required string The tab's key (as TabItem.key).
children required string The label.
icon string | undefined
isDisabled boolean | undefined
isLabelHidden boolean | undefined
badge BadgeValue | false | undefined
badgeLabel string | undefined

<TabPanel>

The content of one tab, a child of <Tabs>: role="tabpanel" (.m3e-tab-panel), labelled by its tab, focusable, hidden while the tab is not selected (it stays mounted).

Prop Type Default Description
id required string The key of the tab this panel belongs to.
children ReactNode

Also accepts HTML attributes not listed here.

Custom elements

<m3e-tabs>

import '@kyralabs/m3e-elements/tabs.js';

<m3e-tabs label="Media" selected="photos"> (COMPONENT_SPEC §17; Compose PrimaryTabRow / SecondaryTabRow / PrimaryScrollableTabRow / SecondaryScrollableTabRow with Tab / LeadingIconTab). Renders the m3e-css .m3e-tabs contract inside itself (the host is display: contents) from its <m3e-tab> children, the DOM m3e-react's <Tabs> renders, and wires its <m3e-tab-panel> children as the APG tab panels. m3e-elements' TabsLayout (shared with m3e-react) moves the focus with the arrow keys, slides the indicator on Compose's spring and scrolls a scrollable row to the selected tab.

  • Attributes: variant (primary, the default, or secondary), scrollable, icon-position (top, the default: icon above the label, or leading: Compose LeadingIconTab), label (the tablist's accessible name), selected (the selected tab's value, reflected; default: the first enabled tab). The host's id prefixes the tab and panel ids.
  • Activation is manual (Compose, m3.material.io): arrows move the focus, a press, Enter or Space selects.
  • Events: selection-change (detail: { value }) when another tab is pressed; cancelable: preventDefault() keeps the selection (controlled), otherwise selected follows.
Attribute Property Type Notes
variant variant string
scrollable scrollable boolean
icon-position iconPosition string
label label string
selected selected string Reflected.
Event Detail Description
selection-change SelectionChangeDetail A tab was picked; cancelable: preventDefault() keeps the selection.

<m3e-tab>

import '@kyralabs/m3e-elements/tabs.js';

<m3e-tab value="photos" icon="photo">Photos</m3e-tab> — data for one tab of a <m3e-tabs> (it renders nothing itself; the row renders the .m3e-tab contract): value (default: the label), icon (Material Symbols, filled while selected), disabled, label-hidden (icon only; the label becomes the accessible name), badge (a Compose Badge on the label; on the icon when the label is hidden or sits under the icon (a stacked tab, D20 (1)): "" the dot, digits a count — 999+ above 999 — other text as is) and badge-label (its visually hidden text, read after the label: default "New notification" for the dot, the shown text otherwise; "" leaves it out). The label is the text content.

Attribute Property Type Notes
value value string
icon icon string
disabled disabled boolean
label-hidden labelHidden boolean
badge badge string
badge-label badgeLabel string

<m3e-tab-panel>

import '@kyralabs/m3e-elements/tabs.js';

<m3e-tab-panel value="photos">…</m3e-tab-panel> — the content of the <m3e-tab> with the same value, a child of the <m3e-tabs>. The row makes it the tab's role="tabpanel" (.m3e-tab-panel, labelled by the tab, focusable) and hides it while the tab is not selected.

Attribute Property Type Notes
value value string

CSS

tabs.css

@import '@kyralabs/m3e-css/tabs.css';

Tabs (COMPONENT_SPEC §17; Compose PrimaryTabRow, SecondaryTabRow, PrimaryScrollableTabRow, SecondaryScrollableTabRow, Tab, LeadingIconTab, TabRowDefaults.PrimaryIndicator / SecondaryIndicator).

<div class="m3e-tabs m3e-tabs--primary">          (--secondary; --scrollable; --leading-icon)
  <div class="m3e-tabs__list" role="tablist" aria-label="Media">
    <button class="m3e-tab" type="button" role="tab" id="t-1" aria-selected="true"
            aria-controls="p-1" tabindex="0">
      <span class="m3e-tab__content">
        <span class="m3e-icon m3e-tab__icon" aria-hidden="true">photo</span>   (optional)
        <span class="m3e-tab__label">Photos</span>       (optional when the tab has an icon
      </span>                                              and an aria-label)
    </button>
    <button class="m3e-tab" type="button" role="tab" aria-selected="false" tabindex="-1">…
  </div>
</div>
<div class="m3e-tab-panel" role="tabpanel" id="p-1" aria-labelledby="t-1" tabindex="0">…</div>

A badge (badge.css) wraps the label in a badged box, as TabSamples.kt LeadingIconTabs (L302) wraps the tab's Text and m3.material.io (Badges › Guidelines) puts a large badge at the trailing edge; with the label hidden it goes on the icon and the tab's aria-label carries its text:

<span class="m3e-badged-box">
  <span class="m3e-tab__label">Inbox</span>
  <span class="m3e-badge m3e-badge--large">
    <span class="m3e-badge__text" aria-hidden="true">999+</span>
    <span class="m3e-badge__label">999+</span>
  </span>
</span>

A stacked tab (icon above the label) puts the badge on the icon (m3.material.io Tabs › Specs: "Overlap of badge on stacked icon 6dp" and the anatomy; D20 (1)). The label comes first and the badged icon follows, drawn above it (order), so the badge's hidden text is read after the label ("Photos 8") and the badge paints over the label, as Compose's TabBaselineLayout emits the text, then the icon (Tab.kt L298-305) and the navigation item does (navigation-item.css). The badge overlaps the icon by --m3e-comp-tabs-stacked-badge-overlap inline (6dp, dot or count), the BadgedBox vertical offsets otherwise. The earlier markup (icon, then a badged label) keeps its styles:

<span class="m3e-tab__label">Photos</span>
<span class="m3e-badged-box">
  <span class="m3e-icon m3e-tab__icon" aria-hidden="true">photo</span>
  <span class="m3e-badge m3e-badge--large">
    <span class="m3e-badge__text" aria-hidden="true">8</span>
    <span class="m3e-badge__label">8</span>
  </span>
</span>

Tab has Modifier.badgeBounds() (Tab.kt L110), LeadingIconTab none: TabsLayout writes the box's --_m3e-badged-box-bounds-end / -top from the tab's edges (badge.css keeps the badge inside the tab), except in a leading-icon tab with an icon and a label. Without TabsLayout the badge is not clamped.

Fixed rows split the width into equal tabs; --scrollable rows size each tab to its content (at least the scrollable minimum width), keep the edge padding before the first and after the last tab and scroll horizontally (no scrollbar, as Compose). A tab with an icon and a label stacks them (72dp); --leading-icon puts the icon before the label (Compose LeadingIconTab, 48dp). The selected tab ([aria-selected='true']) draws the indicator: primary under its content (the content width, at least the indicator minimum width), secondary across the whole tab. Static markup needs no JavaScript. m3e-elements' TabsLayout (also run by m3e-react) slides the indicator from the previous tab: per frame it writes only, on the root,

--_m3e-tabs-indicator-shift  the indicator's offset from its resting place (physical px),
--_m3e-tabs-indicator-width  its width,

plus [data-animating]; at rest both are removed and the CSS above places it. It also scrolls a scrollable row so the selected tab is centred (scrollLeft).

37 custom properties it reads
Custom property Default
--m3e-comp-primary-navigation-tab-active-indicator-height 3px
--m3e-comp-primary-navigation-tab-icon-size 24px
--m3e-comp-secondary-navigation-tab-icon-size 24px
--m3e-comp-tabs-content-color-in-duration var(--m3e-sys-motion-spring-default-effects-duration)
--m3e-comp-tabs-content-color-in-easing var(--m3e-sys-motion-spring-default-effects-easing)
--m3e-comp-tabs-content-color-out-duration var(--m3e-sys-motion-spring-fast-effects-duration)
--m3e-comp-tabs-content-color-out-easing var(--m3e-sys-motion-spring-fast-effects-easing)
--m3e-comp-tabs-divider-color var(--m3e-sys-color-outline-variant)
--m3e-comp-tabs-divider-thickness 1px
--m3e-comp-tabs-horizontal-text-padding 16px
--m3e-comp-tabs-icon-distance-from-baseline 20px
--m3e-comp-tabs-indicator-min-width 24px
--m3e-comp-tabs-label-baseline-shift 0.34
--m3e-comp-tabs-label-max-lines 2
--m3e-comp-tabs-label-text-font var(--m3e-sys-typescale-title-small-font)
--m3e-comp-tabs-label-text-line-height var(--m3e-sys-typescale-title-small-line-height)
--m3e-comp-tabs-label-text-size var(--m3e-sys-typescale-title-small-size)
--m3e-comp-tabs-label-text-tracking var(--m3e-sys-typescale-title-small-tracking)
--m3e-comp-tabs-label-text-weight var(--m3e-sys-typescale-title-small-weight)
--m3e-comp-tabs-large-tab-height 72px
--m3e-comp-tabs-leading-icon-label-space 8px
--m3e-comp-tabs-primary-container-color var(--m3e-sys-color-surface)
--m3e-comp-tabs-primary-content-color var(--m3e-sys-color-primary)
--m3e-comp-tabs-primary-indicator-color var(--m3e-sys-color-primary)
--m3e-comp-tabs-primary-indicator-height 3px
--m3e-comp-tabs-primary-indicator-shape 3px
--m3e-comp-tabs-scrollable-edge-padding 52px
--m3e-comp-tabs-scrollable-min-tab-width 90px
--m3e-comp-tabs-secondary-container-color var(--m3e-sys-color-surface)
--m3e-comp-tabs-secondary-content-color var(--m3e-sys-color-on-surface)
--m3e-comp-tabs-secondary-indicator-color var(--m3e-sys-color-primary)
--m3e-comp-tabs-secondary-indicator-height 3px
--m3e-comp-tabs-secondary-indicator-shape 0px
--m3e-comp-tabs-single-line-text-baseline-with-icon 14px
--m3e-comp-tabs-small-tab-height 48px
--m3e-comp-tabs-stacked-badge-overlap 6px
--m3e-comp-tabs-unselected-content-color set by the component

Differences from Compose

two-line labels in icon + text tabs keep the one-line baseline offset (Compose switches to DoubleLineTextBaselineWithIcon: 8dp shorter); fixed rows split the width exactly (Compose: integer px, the remainder after the last tab); resizing a row at rest snaps the indicator (Compose re-animates it to the new positions); the scroll to the initially selected tab jumps (Compose animates it after the first layout); disabled tabs look enabled (as Compose's Tab(enabled = false)); 20.sp is 20px (no font scaling).

Sources

Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from m3.material.io, as cited below from COMPONENT_SPEC §17 and the m3e-css sources.

TabRow.kt, Tab.kt, TabSamples.kt (vendored), Primary/SecondaryNavigationTabTokens.kt, DividerTokens.kt, TypographyTokens.kt; m3.material.io Tabs (_dsm/content/m3/2026-10-13_06-10-19/f37f47ba-8e67-4f1e-98a3-3bea94879e52.json: Specs measurements, Guidelines, Accessibility keyboard table).

PrimaryNavigationTabTokens.kt / SecondaryNavigationTabTokens.kt@19991f2c (generated primary-navigation-tab.css, secondary-navigation-tab.css) as read by TabRow.kt / Tab.kt, plus components/tabs-defaults.css (PARITY.md lines; references to other component tokens resolved to sys tokens):

tabs.css sources

(compose/material3 @ 19991f2c; values: primary-navigation-tab.css, secondary-navigation-tab.css and tabs-defaults.css, lines in PARITY.md):

  • TabRow.kt TabRowImpl (L390-506): a Surface in the row colors (TabRowDefaults: container ContainerColor, content ActiveLabelTextColor), tabs rowWidth / count wide, the row as tall as the tallest tab; the divider (HorizontalDivider, DividerTokens) along the bottom inside that height, the indicator over it. ScrollableTabRowImpl (L508-650): tabs at least minTabWidth wide from edgePadding, layout padding * 2 + tabs wide, aligned to the start when narrower than the row; the divider spans the row, not the scrolled content.
  • TabIndicatorOffsetNode (L680-751): the indicator is the selected tab's contentWidth (primary: its content, i.e. intrinsic width − 2 × HorizontalTextPadding, at least 24dp) or width (secondary), centred in the tab, at the bottom; PrimaryIndicator: ActiveIndicatorHeight, ActiveIndicatorColor, ActiveIndicatorShape (3dp, every corner); SecondaryIndicator: the same height and color, no shape.
  • Tab.kt: Tab (L91-120, L226-259): a selectable Column filling its slot, centred, ripple in selectedContentColor (LocalContentColor = the row content color); unselectedContentColor defaults to selectedContentColor, so both read the row content color unless --m3e-comp-tabs-unselected-content-color is set (e.g. to the tokens' inactive label color, as m3.material.io shows). TabTransition (L266-290): the color on DefaultEffects to selected, FastEffects to unselected. TabBaselineLayout (L297-414): text with HorizontalTextPadding, at least SmallTabHeight (text or icon) / LargeTabHeight (icon + text) or the content plus IconDistanceFromBaseline; text or icon alone centred; icon + text: the text's last baseline SingleLineTextBaselineWithIcon above the indicator top, the icon IconDistanceFromBaseline above the first baseline (placed from the label's line height, font size and the typeface's baseline position, tabs-defaults.css label-baseline-shift). LeadingIconTab (L152-193): SmallTabHeight row, padding, icon, TextDistanceFromLeadingIcon, text. Label style PrimaryNavigationTabTokens.LabelTextFont.
  • Selected icons fill (COMPONENT_SPEC §0).