Skip to content
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
Getting startedWebReactSwiftUIJetpack ComposeFlutterReact NativePresetsYour own design systemUsing Tanrei with AI agentsBrandDesign tokensDesign systems and customizationTheme StudioColorTypographyIconsShapeElevationMotionInteraction statesBehaviourLayoutAccessibilityWordsColor tokensTypescale tokensShape tokensElevation tokensMotion tokensState tokensSpace and measure tokensComponentsButtonMain action buttonSegmented buttonButton groupButton with menuToolbarCopy buttonCheckboxRadio buttonSwitchChipMenuSliderSlider fieldCalendarDateTimePickerField groupRadio groupCheckbox groupSelectSelect fieldMenubarFile uploadFile picker AdvancedDrop zoneText input AdvancedText fieldChip fieldFieldCombobox AdvancedCombobox fieldNumber input AdvancedNumber fieldColor input AdvancedColor fieldOne-time-code input AdvancedOne-time-code fieldEmbedded text input AdvancedChat composerCardDividerModal layer AdvancedDialogListList rowBottom sheetSide sheetCarouselCode blockTablePopoverStepsAccordionDescription listFigureApp shellPage headerTree viewTimelineImage previewHandleResizable frameScroll areaBadgeProgress indicatorMeterNumber tickerLoading indicatorSnackbarTooltipKeyboard shortcutTagInline codeCalloutAvatarEmpty stateTag groupSkeletonFile itemSwatchMarquee textTabsTab viewApp barNavigation barNavigation drawerSearchLinkPaginationPrevious and next linksStepperBreadcrumbsTable of contentsLink cardFAB menuStatusHow Tanrei is builtContributingLicense and attribution
HomeComponentsTabs

Tabs

Switches between views of the same subject with a row of labelled tabs on a track. One tab is selected, marked by an indicator that slides between the tabs and follows a finger dragged across the row.

content_copyView as Markdown
Category
Navigation
Tokens
comp.tabs
Level
Base
Used in
Tab view

Overview

Web
tanrei-tabs
schedule
SwiftUI
TanreiTabs
schedule
Jetpack Compose
TanreiTabs
schedule
Flutter
TanreiTabBar
schedule
React Native
Tabs
schedule
Variants not shipped in
m3-expressive-like: tonal · m3-like: tonal

Generated from docs/status/status.json, specs/tabs.spec.json

A revised spec, not yet on every platform

This page describes the tabs as they are specified now: tabs on a track, with one indicator that slides to the selected tab and follows a drag across the row, tabs that hug their content by default, labels truncated rather than wrapped, and rows that can share one choice and remember it (group and rememberAs). fullWidth replaces scrollable. A row also has three variants (primary, secondary and tonal), two sizes, a track that can be left out, and a transition that says whether the indicator slides. The web component does all of this, and so do the demos. SwiftUI, Compose, Flutter and React Native are marked lagging above: until they catch up they ship an earlier revision, with scrollable (isScrollable defaulting to false on Flutter), a line indicator, fixed labels that wrap, no track and no drag.

Primary tabs and the panel of the selected one

Three services, all running.

Deployed 2 hours ago.

Region, domains and access.

Playground

Each control sets one property of the live component, and the code follows. The page’s address keeps what you set, so a link opens it as you left it. The code is for the web. For the other platforms, see “Platform name mapping”.

Usage

A row of tabs has one kind of item, tab. Each platform takes the tabs the way it takes any repeated children: <tanrei-tab> child elements on the web, TanreiTab views tagged by value in SwiftUI’s view builder, TanreiTab calls in Compose’s content lambda, TanreiTab widgets in Flutter’s tabs: list, and an items array of plain objects on React Native.

The selection belongs to the row, by value. value holds the value of the selected tab and change carries the new one. SwiftUI calls it selection and Compose selectedValue. Left empty, the first enabled tab is selected. A tab’s disabled is its own.

The tabs do not draw the panels, but they decide which one shows. On the web, <tanrei-tab-panel> elements follow the tabs and are matched to them by value. On SwiftUI each tab carries its panel. On Flutter, TanreiTabBarView shows the panel of the selected value. On Compose and React Native you show the selected panel yourself, right after the row. For panels a person can swipe between, use a Tab view instead: it draws its own row of tabs, or links to a row placed elsewhere, such as in an app bar.

<!-- The row's selected tab is its value, as a <select>'s. -->
<tanrei-tabs value="overview" label="Project">
  <tanrei-tab value="overview" label="Overview" icon="dashboard"></tanrei-tab>
  <tanrei-tab value="activity" label="Activity" icon="history"></tanrei-tab>
  <tanrei-tab value="billing" label="Billing" icon="payments" disabled></tanrei-tab>
  <!-- Panels follow the tabs and are matched to them by value. -->
  <tanrei-tab-panel value="overview">…</tanrei-tab-panel>
  <tanrei-tab-panel value="activity">…</tanrei-tab-panel>
  <tanrei-tab-panel value="billing">…</tanrei-tab-panel>
</tanrei-tabs>

<!-- Rows that name one group show one choice. remember-as keeps it on the device, under your own key. -->
<tanrei-tabs variant="secondary" group="units" remember-as="report-units" label="Units">
  <tanrei-tab value="metric" label="Metric"></tanrei-tab>
  <tanrei-tab value="imperial" label="Imperial"></tanrei-tab>
  <tanrei-tab-panel value="metric">…</tanrei-tab-panel>
  <tanrei-tab-panel value="imperial">…</tanrei-tab-panel>
</tanrei-tabs>

<script type="module">
  import '@tanrei/web/components/tabs';
  // The row sets its own value before it fires change, which carries it.
  document.querySelector('tanrei-tabs').addEventListener('change', (e) => showView(e.detail.value));
</script>
import { Tab, TabPanel, Tabs } from '@tanrei/react';

<Tabs value={view} label="Project" onChange={(e) => setView(e.detail.value)}>
  <Tab value="overview" label="Overview" icon="dashboard" />
  <Tab value="activity" label="Activity" icon="history" />
  <TabPanel value="overview"><Overview /></TabPanel>
  <TabPanel value="activity"><Activity /></TabPanel>
</Tabs>

// Rows that name one group show one choice; only the row the person used fires onChange.
<Tabs variant="secondary" group="units" rememberAs="report-units" label="Units" onChange={(e) => setUnits(e.detail.value)}>
  <Tab value="metric" label="Metric" />
  <Tab value="imperial" label="Imperial" />
  <TabPanel value="metric"><Metric /></TabPanel>
  <TabPanel value="imperial"><Imperial /></TabPanel>
</Tabs>
import Tanrei

// As TabView's tagged tabs: each tab carries its panel, and only the selected one is shown.
// Writing the binding is change. A nil selection (Binding<V?>) selects the first enabled tab.
// Apply .tag(_:) and .labelStyle(.iconOnly) to the TanreiTab itself, before any other modifier.
@State private var view = "overview"

TanreiTabs(selection: $view, label: "Project") {
    TanreiTab("Overview", icon: "dashboard") { OverviewView() }.tag("overview")
    TanreiTab("Activity", icon: "history") { ActivityView() }.tag("activity")
    TanreiTab("Billing", icon: "payments") { BillingView() }.tag("billing").disabled(true)
}
import io.github.chnotchy.tanrei.components.TanreiTab
import io.github.chnotchy.tanrei.components.TanreiTabs
import io.github.chnotchy.tanrei.primitives.TanreiText

// State hoisting: you keep selectedValue and move it from onSelectedValueChange.
var view by rememberSaveable { mutableStateOf("overview") }
Column {
    TanreiTabs(selectedValue = view, onSelectedValueChange = { view = it }, contentDescription = "Project") {
        TanreiTab(value = "overview", label = "Overview", icon = { TanreiText("dashboard") })
        TanreiTab(value = "activity", label = "Activity", icon = { TanreiText("history") })
        // enabled is the inverse of the spec's disabled.
        TanreiTab(value = "billing", label = "Billing", icon = { TanreiText("payments") }, enabled = false)
    }
    // Compose has no tab panel role: show the selected view below the row, in reading order.
    when (view) {
        "overview" -> Overview()
        "activity" -> Activity()
    }
}
import 'package:tanrei/tanrei.dart';

// As TabBar, but selected by value instead of a TabController's index. The bar keeps no selection of its own.
TanreiTabBar<String>(
  value: view, // null selects the first enabled tab
  onChanged: (String value) => setState(() => view = value),
  semanticLabel: 'Project',
  tabs: const <TanreiTab<String>>[
    TanreiTab<String>(value: 'overview', label: 'Overview', icon: Icon(AppSymbols.dashboard)),
    TanreiTab<String>(value: 'activity', label: 'Activity', icon: Icon(AppSymbols.history)),
    TanreiTab<String>(value: 'billing', label: 'Billing', icon: Icon(AppSymbols.payments), enabled: false),
  ],
),
// Shows the view of the selected value, and does not swipe. Each view is a tab panel named by its tab's label.
TanreiTabBarView<String>(
  value: view,
  semanticLabels: const <String, String>{'overview': 'Overview', 'activity': 'Activity', 'billing': 'Billing'},
  children: <String, Widget>{'overview': const OverviewPage(), 'activity': const ActivityPage(), 'billing': const BillingPage()},
),
import { useState } from 'react';
import { Tabs } from '@tanrei/react-native';

const [view, setView] = useState('overview');

// Controlled: move value from onValueChange.
<Tabs
  accessibilityLabel="Project"
  value={view}
  onValueChange={setView}
  items={[
    { value: 'overview', label: 'Overview', icon: 'dashboard' },
    { value: 'activity', label: 'Activity', icon: 'history' },
    { value: 'billing', label: 'Billing', icon: 'payments', disabled: true },
  ]}
/>
// React Native has no tab panel role: render the selected panel right after the row.
{view === 'overview' ? <Overview /> : <Activity />}

Specs

Secondary tabs, a row that fills, a row that hugs and scrolls, and icons alone

Tonal tabs with their track, without it, and small without it and without a transition

Anatomy

Anatomy

Drawn over the fixture primary, as the web package draws it.
Part Description
1 tablist The row that holds the tabs. It lays them out on the track, hugging their content (scrolling when they do not fit) or sharing its width, moves keyboard focus among them, and follows a drag across it with the active indicator.
2 track The constant background of the whole row, a capsule in the default system: one color per variant, the same whichever tab is selected and whatever state a tab is in, with no outline. Tabs are one choice among equals, so the track never says which one is selected; the active indicator does. It may be left out (track): the row then stands on the surface it is placed on and keeps the track's inset, corners and clip, so that nothing moves. A layer whose tabs sit on a line makes it transparent.
3 container One tab's state layer, the area that draws hover, focus and press: the box the active indicator has on this tab while it is selected, or would have if it were. In the default system that is a capsule, or a circle where the tab's content is not wider than it is tall, and in a row that fills it is as wide as the tab; it is never the tab's rectangle. The area that answers hover and a tap is still the whole tab.
4 icon · optional A glyph above the label (a primary tab of the medium size) or before it (a secondary or tonal tab, and every tab of the small size).
5 label · optional The tab's text and its accessible name, always on one line. A tab that hugs its content shows it whole; a tab that shares the row's width truncates it with an ellipsis and shows it whole where the platform can. It may be hidden for an icon-only tab (labelHidden) and still name it.
6 active-indicator The thumb: the shape behind the selected tab's content — filled in primary and secondary tabs, a translucent layer in tonal ones — and, with the selected label's color, the mark of the selection in the default system. Its height is the row's less the track's inset, the same on every tab, and on the first and the last tab it lies that same inset from the track's end, so the thumb's round end and the track's are concentric at every row height. In a row that hugs it is as wide as the selected tab's content plus padding, and a circle where that content is not wider than it is tall (an icon, one full-width character, a one-letter label); in a row that fills it is as wide as the tab. It slides to the newly selected tab, or is drawn on it at once where the row's transition is none, and follows a finger dragged across the row, and the label and icon take their covered colors wherever it covers them, so their inversion travels with it.
7 underline The line on the row's bottom edge under the selected tab: as wide as the tab's content in primary tabs, as wide as the tab in secondary tabs. It moves with the active indicator. The default system leaves it transparent; a layer that marks the selection with a line (Material's tabs) colors it.
8 divider The hairline along the bottom of the whole row, under the underline. The default system leaves it transparent, because its row is a track and needs no line; a layer whose tabs sit on a line colors it.
9 panel The view a tab shows. The tabs associate the selected tab with it and hide the others; they do not draw it. A row's panels are its own or, where a Tab view draws the row or is linked to it, the Tab view's, which switches and swipes them. A row may have none: it then switches what the product draws below it, and says so by its name alone (TAB-42).
10 focus-ring Gap-then-ring indicator shown for keyboard focus, drawn around the tab's container, so that it surrounds the place the active indicator has on that tab (a capsule or a circle in the default system), and held inside the track.

Measurements

Measurements

Length Token Value (px)
container padding-inline tanrei.comp.tabs.container.padding-inline.medium 16
container height tanrei.comp.tabs.container.height.medium 48
active-indicator height tanrei.comp.tabs.active-indicator.height 32
Drawn over the fixture primary, as the web package draws it.

Properties

Property Type Default Description
variant primary | secondary | tonal "primary" How much the selection weighs. Primary tabs switch the main views of a screen, at the top of its content: the active indicator is the filled color. Secondary tabs switch within one of those views: the indicator is one step quieter. Tonal tabs are the quiet row, for a choice that should not compete with what it changes — the language of a code sample, a contrast, a unit, a period: the indicator is a tonal layer, under a label that is darker than the others. The three are the same control in every other respect (TAB-50), and each is drawn with or without its track (track), at each size (size).
size small | medium "medium" The row's height (32 / 48) and what shrinks with it: a tab's padding, the active indicator's margins and the icon. Medium is the row of a screen's views, as tall as the touch target. Small stands in a header or a toolbar, as tall as a small Button; its hit area still meets the minimum target (TAB-5), and its icon stands before the label in every variant. The label's text style, the colors and the motion are the same at both.
track boolean null Whether the row draws its track, the tonal background behind all its tabs. False draws none: the row stands on the surface it is placed on — in a header that is already a bar, on a card, in the heading of a sample — and nothing else changes, neither its size nor a tab's place nor a color of its variant (TAB-49). True draws it. Null, the default, takes comp.tabs.track.visible, the system's choice, which is on in the default system. Independent of variant: each of the three is drawn both ways.
fullWidth boolean false False (hug, the default) sizes each tab to its content, never truncating its label, and scrolls the tabs sideways inside the track when they do not fit; the active indicator's width then follows the selected tab's content. True (fill) divides the row's width equally among the tabs and truncates a label that does not fit its share (for two to five short labels); the active indicator's width is then the tab's, the same on every tab.
orientation horizontal | vertical "horizontal" The direction the tabs run. Horizontal (the default) is a row above the views it switches. Vertical stacks the tabs in a column at the start edge, beside the views — the categories of a settings page or of a dialog, the sections of one record — with every tab as wide as the column and its icon before its label (TAB-51). It switches views of one subject as a row does; it is not navigation between the destinations of an app, which is the navigation drawer's. fullWidth does not apply to a column (TAB-56).
value string null The value of the selected tab. The tabs set it when a tab is activated, or when a drag across the row is released on another tab, and emit change; the product may also set it. null selects the first enabled tab. While the row is a member of a group (group), the group's choice selects the tab of that value wherever the row has one (TAB-36).
indicatorOffset number 0 Where the active indicator and the underline are drawn while the Tab view it belongs to is swiped, relative to the selected tab: 0 on it, 1 on the next enabled tab and −1 on the previous one in reading order, and in between for values between. The Tab view sets it; a product leaves it at 0 unless it drives the indicator itself. A drag across the row itself is followed by the tabs (TAB-28) and does not pass through this prop.
transition auto | slide | none "auto" How this row draws a new selection: the row's part of the effect of switching views. slide: the active indicator and the underline slide and resize to the newly selected tab, the labels' colors changing under them, and a tab out of view is scrolled in with a motion. none: the new selection is drawn whole in one frame — nothing travels and nothing fades (TAB-43). Auto takes comp.tabs.motion.transition, the design system's answer (slide in the default system). Under both a drag across the row still carries the indicator and hover, focus and press are answered the same; reduced motion draws none whatever this says. A Tab view has a transition of its own for its panels, and the row it draws follows it where it is none (TBV-22, TBV-23).
label string "" The accessible name of the row: what the views are views of, or, for a row without panels, what is chosen (Contrast, Units). Needed when a screen has more than one row of tabs, and always for a row that has no panel, where nothing else says what the row is for (TAB-42).
group string "" Names a choice this row shares with other rows of tabs, placed by the product or drawn by a Tab view. Rows that name the same group show the same choice: selecting a tab in one selects the tab of the same value in every other row that has one, at once and without moving focus or the page, and a row that has no tab of that value keeps its own (TAB-36 to TAB-38, TAB-40). Empty (the default): the row's choice is its own. The name is the product's and is compared exactly. On a platform where the product keeps the selection in its own state, rows given the same state are the group, and each platform's mapping says how it is spelled.
rememberAs string "" The key under which a person's choice on this row is kept on their device across pages and visits, and from which it is restored before the row is first drawn (TAB-39). Empty (the default): the row neither reads nor writes any storage; nothing is remembered without a key. The key is the product's own and is used exactly as given, so the product can name it in its privacy notice, read it itself and clear it. Only the chosen tab's value is stored. It needs no group, and a group needs no key: sharing and remembering are separate choices.

Events

Event Description
change The user selected another tab, by activating it or by releasing a drag on it; carries its value, which the row's value now holds.

Items

The kinds of item the component repeats. A fixture lists its items in order; each platform names each kind in its own idiom.

tab

One tab of the row: a label, an optional icon, and the value that selects it.

Property Type Default Description
value string "" Identifies the tab: the row's value names the selected tab by it, and change carries it. Unique within the row; required.
label string "" The tab's text and its accessible name. Required, even when labelHidden is true.
icon string "" The name of an icon (a glyph of the system's icon set). Give every tab of a row an icon, or none.
labelHidden boolean false Draws the icon alone and keeps label as the accessible name (an icon-only tab).
disabled boolean false Prevents selecting the tab; the arrow keys skip it.
Spec Web SwiftUI Jetpack Compose Flutter React Native
Item tanrei-tab TanreiTab TanreiTab TanreiTab Tab
value · Property value tag value value value
label · Property label title label label label
icon · Property icon icon icon icon icon
labelHidden · Property labelHidden labelStyle labelHidden labelHidden labelHidden
disabled · Property disabled disabled enabled enabled disabled
Notes A child element: <tanrei-tab value="overview" label="Overview" icon="dashboard">, with label-hidden and disabled attributes, as <option> in a <select>. TanreiTab(_ title:, icon:) { panel }.tag(value) in the row's view builder, as TabView's tagged tabs; .labelStyle(.iconOnly) is labelHidden; disabled follows .disabled(_:). TanreiTab(value, label, icon, labelHidden, enabled) called in the TanreiTabs { } content lambda. enabled is the inverse flag. TanreiTab<T>(value:, label:, icon:, labelHidden:, enabled:) widgets in the bar's tabs: list. The element type of the row's items array: { value, label, icon?, labelHidden?, disabled? }.

States

Behavior

Requirements in EARS form. Every platform implementation is tested against these ids.

ID Requirement
TAB-1 WHEN the user activates an enabled tab that is not selected by pointer, touch, Enter or Space THE tabs SHALL set value to the tab's value and emit change exactly once with it; WHEN the tab is already selected THE tabs SHALL emit nothing.
TAB-2 WHILE a tab's disabled is true THE tabs SHALL NOT select it or emit change for it, SHALL skip it with the arrow keys, SHALL NOT make it the Tab stop and SHALL expose the disabled state to assistive technology.
TAB-3 WHILE a tab is hovered, keyboard-focused or pressed THE tabs SHALL render its container, label, icon, covered label and icon, active indicator, underline and opacity from the component tokens of the highest-precedence active state, with precedence pressed over focused over hovered; focus gained by pointer or touch SHALL NOT select the focused state.
TAB-4 WHEN a tab receives keyboard focus THE tabs SHALL show the focus indicator (the ring or the glow, TAB-10) around the tab's container (TAB-18), concentric with it and in its shape, so that it surrounds the place the active indicator has on that tab, with its inner edge sys.focus.offset outside the container's edge. THE tabs SHALL hold the indicator inside the track, which clips a row that scrolls: WHERE the space above and below the container (comp.tabs.track.padding) is less than the indicator reaches, it SHALL be drawn further in by the difference, on every side alike, keeping the container's shape. THE indicator SHALL be drawn above the neighbouring tabs and the active indicator, without animation; focus gained by pointer or touch SHALL NOT show it.
TAB-5 WHERE a tab is narrower than the minimum target THE tabs SHALL make it at least sys.target.touch wide for touch input and sys.target.pointer wide for fine pointers, in layout, because tabs touch their neighbours, so a hit area cannot extend over them. WHERE the row is shorter than the minimum target (the small size) THE tabs SHALL extend each tab's hit area above and below the row, by the same distance on each side, to sys.target.touch for touch input and sys.target.pointer for fine pointers, without changing the row's height or the place of anything around it; WHERE that extension lies over the row's own panel or over a control beside the row, a press on something that panel or control would itself answer SHALL go to it and not to the tab. A platform that cannot tell the input type SHALL use sys.target.touch.
TAB-6 WHILE a tab's disabled is true THE tabs SHALL render its container, label, icon, covered label and icon, active indicator, underline and opacity from the disabled component tokens of its variant and, for the selected tab, of its selected parts.
TAB-7 WHEN variant or size names a value that the active design system does not have THE tabs SHALL render with the tokens of that prop's default value, and in debug builds report the unknown value.
TAB-8 WHEN the tab is pressed by pointer, touch, Enter or Space THE tab SHALL show the pressed state without delay and SHALL keep it visible for at least sys.motion.duration.press-hold, even when the press ends sooner.
TAB-9 WHILE pressed THE tab SHALL answer as sys.behavior.pressed-feedback names: overlay applies the pressed tokens to the whole container at once; ripple grows the pressed state layer from the point of contact (from the center for keyboard activation); dim applies the pressed opacity to the whole control and draws no state layer; none draws no answer to the press. Under ripple and dim only the container color stays with the state under the press; label, icon, covered label and icon, active indicator, underline, opacity and shape still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.tabs.motion.feedback-duration with comp.tabs.motion.state-easing, fades over comp.tabs.motion.state-duration with comp.tabs.motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced.
TAB-10 THE tab SHALL ease its container and track corners by sys.shape.corner-smoothing (0 is a quarter circle, 0.6 the iOS continuous corner; a platform with only the OS curve draws it for any amount above 0) and show its keyboard focus as sys.behavior.focus-style names (ring or glow); a platform that cannot draw a choice SHALL fall back to the first value and state that in its README.
TAB-11 THE tabs SHALL render the tab whose value equals the row's value (or, while the row's value is null, the first enabled tab) from the selected parts of its variant (comp.tabs.primary.selected.label.color and the other selected. parts for primary, and so on) and every other tab from the variant's own parts; WHEN value names no tab THE tabs SHALL render none as selected, show no panel, and in debug builds report the value; WHEN a tab's value is empty or repeats another tab's THE tabs SHALL still render it, treat the first tab with a value as the one that value selects, and in debug builds report the tab.
TAB-12 THE tabs SHALL be one Tab stop, the selected tab; WHILE orientation is horizontal and a tab has keyboard focus Right Arrow and Left Arrow SHALL move focus to the next and previous enabled tab, wrapping at the ends, and Home and End to the first and last enabled tab, and each move SHALL also select the tab it reaches and emit change (automatic activation); in a right-to-left layout Left Arrow SHALL move to the next tab and Right Arrow to the previous; WHILE orientation is vertical the arrow keys SHALL move and select as TAB-54 says, and WHERE the row has no panel, in either orientation, as TAB-42 says; in either orientation Tab SHALL move focus from the selected tab into its panel, or, WHERE the row has no panel (TAB-42), to whatever follows the row.
TAB-13 WHILE the row has a panel THE tabs SHALL expose the tab list role named by the row's label (over a name the product gives the row on the element, aria-label or aria-labelledby, which names it WHILE label is empty), each tab with the tab role, its selected state, its position and the number of tabs, named by its own label whether or not that label is drawn; THE tabs SHALL expose the selected tab's panel with the tab panel role, named by its tab and associated with it where the platform can express that, SHALL hide the other panels from view and from assistive technology, and SHALL make a panel with no focusable content itself focusable so that Tab reaches it. WHERE the tabs belong to a Tab view, as the row it draws itself (TBV-16) or as a row it is linked to (TBV-17), its panels take the place of the tabs' own (TBV-9, TBV-15), and each tab SHALL be associated with the Tab view's panel of its value. A row that has no panel is not a tab list: it is exposed as the single choice it is, as TAB-42 says.
TAB-14 WHILE fullWidth is false THE tabs SHALL size each tab to its content plus the padding of the row's size on each side (comp.tabs.container.padding-inline.medium for medium, comp.tabs.container.padding-inline.small for small; TAB-44), or, WHERE its active indicator is a circle (TAB-16), to that circle and no wider, in either case never narrower than its active indicator or than the minimum target (TAB-5), keep its label on one line and whole however long it is or however large the text, place the tabs so that the first tab's active indicator starts comp.tabs.track.padding in from the track's start edge and the last tab's ends the same distance before the track's end (TAB-34), and scroll them sideways inside the track when they are wider than the space the row has; WHILE fullWidth is true THE tabs SHALL divide the row's width, less comp.tabs.track.padding at each end, equally among the tabs, each at least as wide as the minimum target (the tabs scroll sideways only when the row is too narrow for that), keep each label on one line, and truncate a label wider than its tab less the padding of the row's size on each side (and, beside an icon, less the icon and comp.tabs.container.gap) at the end of the label's own direction (TAB-33), with an ellipsis. A label SHALL NOT wrap onto a second line or break within a word, and an icon SHALL NOT shrink to make room for it.
TAB-15 WHEN the row first appears with its selected tab partly or wholly out of view, or a tab becomes selected or receives keyboard focus while part of it is scrolled out of view, THE tabs SHALL scroll the tabs inside the track until the whole tab is visible (at once when the row first appears), over comp.tabs.motion.indicator-duration with comp.tabs.motion.indicator-easing, and at once where motion is reduced. WHERE the tab becomes selected because the row follows its group's choice THE tabs SHALL bring it into view at once (TAB-38). WHILE the row's transition is none (TAB-43) THE tabs SHALL bring a tab that becomes selected into view at once.
TAB-16 THE tabs SHALL draw the selected tab's active indicator behind its content and centred on it, as tall as the row less comp.tabs.track.padding above and below, which is the same height on every tab of the row whatever the tab holds. WHILE fullWidth is false the indicator SHALL be as wide as the tab's content (its label and icon as TAB-18 lays them out, the icon alone while the label is hidden) plus the active indicator's padding of the row's size on each side (comp.tabs.active-indicator.padding-inline.medium for medium, comp.tabs.active-indicator.padding-inline.small for small), never wider than the tab; WHERE that content is not wider than it is tall (an icon alone, one full-width character, a one-letter label) the indicator SHALL instead be exactly as wide as it is tall, so that it is a circle. WHILE fullWidth is true the indicator SHALL be as wide as the tab, whatever the tab holds. In either case it SHALL NOT be narrower than it is tall, its width SHALL follow the same rule in every variant, and its four corners SHALL be rounded by comp.tabs.primary.active-indicator.shape for primary, comp.tabs.secondary.active-indicator.shape for secondary and comp.tabs.tonal.active-indicator.shape for tonal, each at most half its height. THE tabs SHALL also draw the selected tab's underline on the bottom edge of the row, above the divider, comp.tabs.primary.underline.thickness thick for primary, comp.tabs.secondary.underline.thickness for secondary and comp.tabs.tonal.underline.thickness for tonal, with the corners that face away from the divider rounded by comp.tabs.primary.underline.shape for primary, comp.tabs.secondary.underline.shape for secondary and comp.tabs.tonal.underline.shape for tonal, spanning the tab's content for primary and the tab's whole width for secondary and tonal. Both are drawn in every system, each in its look's color; a transparent color (the default system's underline, and the active indicator of a system that marks the selection with the line) hides the part without changing the layout. The content the indicator's width is measured from SHALL be the label at the heavier of its two weights (TAB-50), so that no tab, indicator or container changes width when the selection moves. A tab with no content (an empty label and no icon, which TAB-22 reports in debug builds) has an underline of no width and a circular active indicator; the report, not a wider indicator, is the remedy.
TAB-17 WHEN the selected tab changes while indicatorOffset is 0 and no drag is followed (TAB-28), and the row's transition is slide (TAB-43), THE tabs SHALL slide and resize the active indicator and the underline together from the old tab to the new one over comp.tabs.motion.indicator-duration with comp.tabs.motion.indicator-easing, drawn in the newly selected tab's colors, every frame of the slide being the in-between look of TAB-28 (the indicator as a drag would draw it at that place: its width between the two tabs' indicators, a circle becoming a capsule and a capsule a circle, each label and icon in its covered color exactly where it is covered), and SHALL move them at once where motion is reduced; the panel SHALL change at once unless a linked Tab view switches it (TBV-2). WHEN the row first appears, or the selected tab changes size or place without a new selection (a font arriving, the text size changing, the row resizing), THE tabs SHALL put the active indicator and the underline on it at once, without a slide. WHERE the selected tab changes because the row follows a choice of its group that was made on another row, or one restored from storage, THE tabs SHALL likewise put them on it at once (TAB-38).
TAB-18 THE tabs SHALL make every tab of a row as tall as the row: at least the container height of the row's size (comp.tabs.container.height.medium for medium, comp.tabs.container.height.small for small), at least comp.tabs.container.height-with-icon where a primary tab stacks an icon above a drawn label, and tall enough that the content of its tallest tab keeps the indicator's padding of that size (comp.tabs.active-indicator.padding-block.medium for medium, comp.tabs.active-indicator.padding-block.small for small) between itself and the active indicator's top and bottom edges (TAB-16); each tab's content SHALL be centred in it and clear of the underline's thickness at the bottom edge. THE tabs SHALL draw each tab's container, which carries hover, focus and press, in the box the active indicator has on that tab while it is selected, or would have if it were (TAB-16), centred in the tab: as tall as the row less comp.tabs.track.padding above and below; WHILE fullWidth is false as wide as the tab's content plus the active indicator's padding of the row's size on each side (TAB-16), and exactly as wide as it is tall WHERE that content is not wider than it is tall; WHILE fullWidth is true as wide as the tab. Its corners SHALL be rounded by comp.tabs.container.shape, each at most half its height, and THE tabs SHALL NOT draw hover, focus or press on the part of the tab's rectangle outside that box. The container SHALL lie above the track and under the active indicator, except on the selected tab, whose container SHALL lie above the active indicator and under the focus indicator, drawn from that tab's selected parts (its state layer is comp.tabs.primary.selected.state-layer.color, comp.tabs.secondary.selected.state-layer.color or comp.tabs.tonal.selected.state-layer.color by variant, the color of the content the indicator covers), so that hover and press show on the selected tab as on any other; the area that answers hover and a press SHALL remain the whole tab (TAB-5): a pointer anywhere on the tab shows the state, in the container. A primary tab with an icon and a drawn label SHALL place them as the icon placement of the row's size says (comp.tabs.icon.placement.medium for medium, comp.tabs.icon.placement.small for small, and a size a layer added by its own value, or medium's WHERE the layer declares none): stacked puts the icon above the label, comp.tabs.container.gap apart; inline puts it before the label, comp.tabs.container.gap apart, centred on the label's line. A secondary or tonal tab SHALL place its icon before its label at every size, as inline does. THE tabs SHALL draw the icon at the icon size of the row's size (comp.tabs.icon.size.medium for medium, comp.tabs.icon.size.small for small) through the icon helper with the family and fill of the tab's selection (selected for the selected tab, otherwise unselected), sys.icon.weight and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max, and the label in comp.tabs.label.typescale.
TAB-19 WHILE a tab's labelHidden is true THE tabs SHALL draw only the tab's icon and keep its label as its accessible name; WHILE labelHidden is true and icon is empty THE tabs SHALL draw the label anyway and in debug builds report the tab.
TAB-20 THE tabs SHALL draw a line of comp.tabs.divider.thickness in comp.tabs.divider.color along the bottom of the whole row, under the tabs, the active indicator and the underline, across the full width of the row even while its tabs scroll, and SHALL hide it from assistive technology. The line is drawn in every system; a transparent color (the default system's) hides it without changing the layout.
TAB-21 WHEN the layout direction is right-to-left THE tabs SHALL order the tabs from the right, start the track and a row of tabs that hug their content at the right edge, truncate a label at the end of the label's own direction (TAB-33: a right-to-left label at its left end, a left-to-right label at its right end), and mirror the slide of the active indicator and the underline; a drag (TAB-28) SHALL still carry them to where the pointer is; WHILE orientation is vertical the column SHALL still run from top to bottom, its start edge being its right one: each tab's icon and label SHALL start at the tab's right edge, and the divider and the underline SHALL lie on the column's left edge (TAB-53).
TAB-22 WHILE a tab's label is not empty THE tabs SHALL expose it as the tab's accessible name, over a name the product gives the tab on the element (aria-label or aria-labelledby); WHILE a tab's label is empty and the product gives the tab a name of its own on the element — aria-label, or aria-labelledby naming an element — THE tabs SHALL leave that name to the tab; WHILE a tab's label is empty and the product gives it none THE tabs SHALL still render the tab and in debug builds report that it has no accessible name.
TAB-23 WHEN the colors of a tab change between states or between selected and not selected THE tabs SHALL animate them over comp.tabs.motion.state-duration with comp.tabs.motion.state-easing, and SHALL change them at once where motion is reduced. WHERE a tab becomes selected or stops being selected because the row follows a choice of its group that was made on another row, or one restored from storage, THE tabs SHALL change its colors at once (TAB-38). WHILE the row's transition is none THE tabs SHALL change the colors of a tab that becomes selected or stops being selected at once (TAB-43), and SHALL still animate a change between states. WHEN the row first appears THE tabs SHALL draw every tab's colors, its label's weight (TAB-50) and its icon's fill (TAB-18) at once, as they are in that first frame, without a motion; WHEN a tab is added to a row that is already drawn THE tabs SHALL draw that tab so too. An appearance is not a change between states.
TAB-24 THE tabs SHALL draw each tab's label and icon a second time, over the first, in the covered colors of the tab's look (comp.tabs.primary.covered-label.color and comp.tabs.primary.covered-icon.color for a primary tab that is not selected, comp.tabs.primary.selected.covered-label.color and comp.tabs.primary.selected.covered-icon.color for the selected one, and the same parts of secondary and of tonal), clipped to the active indicator's current shape, its corners included, wherever the indicator is drawn; WHILE the indicator slides (TAB-17) the clip SHALL move with it in the same frame, so that each label and icon it passes over changes where it is covered and the newly selected tab's content is wholly in its covered colors when the indicator lands, neither before nor after. THE tabs SHALL NOT approximate the covered colors with a blend mode, and SHALL expose each label to assistive technology once.
TAB-25 WHILE a tab's label is truncated (TAB-14) THE tabs SHALL keep the whole label as the tab's accessible name and SHALL show the whole label through the platform's idiom for revealing truncated text where it has one — a plain tooltip on pointer hover and keyboard focus, or on long press where tooltips open that way, or the platform's large-content viewer — without exposing it to assistive technology a second time; WHILE the label fits THE tabs SHALL show no such tooltip.
TAB-26 WHILE indicatorOffset is not 0 THE tabs SHALL draw the active indicator and the underline between the selected tab and the next enabled tab (above 0) or the previous enabled tab (below 0) in reading order, each edge at the linear interpolation between its place on the one tab and on the other by the offset's magnitude, clamped to 1, in the selected tab's colors, and SHALL follow the offset in the frame it changes, without a motion of their own; the covered label and icon SHALL follow as TAB-24 says; WHERE there is no enabled tab in the offset's direction THE tabs SHALL draw them on the selected tab. WHEN value changes while indicatorOffset is not 0 THE tabs SHALL place them at once by the new selected tab and the offset, without the slide of TAB-17. WHILE indicatorOffset is not 0 THE tabs SHALL take no drag of their own (TAB-28): the swipe that sets it already has the indicator.
TAB-27 THE tabs SHALL draw a track behind the whole row, under the tabs, their containers and the active indicator: filled, WHILE the row's track is on (TAB-49), with comp.tabs.primary.track.color for primary, comp.tabs.secondary.track.color for secondary and comp.tabs.tonal.track.color for tonal, its corners rounded by comp.tabs.track.shape, each at most half its height, with no outline, as tall as the row, and as wide as the tabs plus comp.tabs.track.padding at each end but never wider than the space the row has; WHILE the tabs do not fit that space THE tabs SHALL keep the track still and scroll the tabs inside it, clipped to its shape (TAB-14). THE track SHALL be the same whichever tab is selected, whatever state any tab is in and while the active indicator moves, because tabs are one choice among equals: only the active indicator marks the selection. THE tabs SHALL hide the track from assistive technology. The track is drawn in every system; a transparent color hides it without changing the layout. So does a row whose track is off (TAB-49): it fills nothing and keeps the track's box, inset, corners and clip.
TAB-28 WHEN the user presses the row with touch, a pen or a mouse and drags past the platform's touch slop (8 points where the platform names none), further sideways than up or down, THE tabs SHALL take the drag: the press SHALL activate no tab when it ends (TAB-1), the tab under it SHALL leave its pressed state as it does when a press ends (TAB-8), and from then until the drag ends THE tabs SHALL draw the active indicator and the underline where the pointer is along the row — on an enabled tab while the pointer is at that tab's centre, halfway between two neighbouring enabled tabs while the pointer is where they meet (the middle of any disabled tabs between them), each edge at the linear interpolation in between, and on the first or last enabled tab while the pointer is before or beyond its centre — in the selected tab's colors. Everything that differs between the indicator on the one tab and on the other SHALL change continuously with the pointer, with no threshold: its place, its width (between the two tabs' indicators, a circle widening into a capsule and a capsule narrowing into a circle), the underline's place and width with it, and the color of each label and icon, which is its covered color exactly where the indicator covers it and its own color elsewhere, the clip of TAB-24 moving with the indicator in the same frame. THE tabs SHALL place them in the frame the pointer moves, without a motion of their own; WHEN the drag is taken with the pointer away from where they rest THE tabs SHALL bring them to the pointer's place over comp.tabs.motion.state-duration with comp.tabs.motion.state-easing instead of at once, following the pointer meanwhile. A drag that is further up or down than sideways when it passes the slop SHALL be left to vertical scrolling for its whole length, and a wheel or a trackpad SHALL only scroll. WHILE the tabs do not fit and scroll inside the track (TAB-14) THE tabs SHALL take only a drag that starts on the selected tab, SHALL leave every other sideways drag to scroll the tabs as before, and SHALL scroll the tabs to keep the active indicator in view while a drag carries it toward an end of the track.
TAB-29 WHEN the user releases a drag the tabs have taken THE tabs SHALL choose one enabled tab: the one the active indicator is nearer to of the two it lies between (the one after it from halfway on), or, WHEN the release is a fling along the row, the one of those two that the fling is toward; a fling is a release faster than the platform's minimum fling velocity (Android's ViewConfiguration.getScaledMinimumFlingVelocity, Flutter's kMinFlingVelocity) or, where the platform names none (the web, SwiftUI, React Native), 0.5 points per millisecond. A release's velocity is the pointer's over its last moves: WHERE the pointer has not moved for longer than the time after which the platform's velocity tracker takes a pointer as stopped (40 milliseconds in Android's VelocityTracker, Jetpack Compose's VelocityTracker and Flutter's VelocityTracker) or, where the platform names none (the web, SwiftUI, React Native), 40 milliseconds before the release THE tabs SHALL take the release as having no velocity, so that a drag that stops where it is wanted and is let go there is no fling, however fast it moved before it stopped. WHERE the chosen tab is not the selected one THE tabs SHALL set value to the tab's value and emit change exactly once with it, and SHALL slide and resize the active indicator and the underline from where the release left them onto that tab over comp.tabs.motion.indicator-duration with comp.tabs.motion.indicator-easing, as TAB-17 does from a tab; WHERE it is the selected tab THE tabs SHALL return them to it over the same motion and emit nothing. The place where two tabs meet decides only what a release chooses; nothing drawn changes at it. The chosen tab SHALL become the row's Tab stop (TAB-12), and the release SHALL NOT also activate the tab under the pointer.
TAB-30 WHEN a drag the tabs have taken is cancelled — the platform cancels the pointer (a system gesture, a lost capture), Escape is pressed while it lasts, or the product sets value or removes or disables a tab the indicator lies between — THE tabs SHALL return the active indicator and the underline to the selected tab (the one the product has just set, where it set one) over comp.tabs.motion.indicator-duration with comp.tabs.motion.indicator-easing and emit nothing. WHILE a drag lasts THE tabs SHALL NOT change value, the panel shown (their own, or a Tab view's, whose shown panel cannot change before a tab is chosen), keyboard focus, or the selected state that each tab exposes to assistive technology, whatever the indicator covers, and SHALL announce nothing: the selection is made, and announced, by the release alone (TAB-29). WHERE motion is reduced THE tabs SHALL still draw the active indicator and the underline where the pointer is, which the person's own finger moves, taking the drag without the catch-up motion of TAB-28, and SHALL put them on their tab at once when the drag is released or cancelled.
TAB-31 THE tabs SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the tabs is shown THE tabs SHALL draw itself for the new direction at once, without a motion of its own, keeping the selected tab, scrolled into view as TAB-15 says, and its focus.
TAB-32 WHERE the layout direction is right-to-left THE tabs SHALL draw a tab's icon, in both of its copies (TAB-24) mirrored left to right exactly when the system's icon set marks that glyph's name as mirrored or the product forces mirroring for that use, and unmirrored when the product forbids it: the icon helper mirrors the glyph inside its own box, about the box's vertical centre line, changing neither its place, size, color and fill nor the order TAB-21 gives. THE tabs SHALL NOT mirror a glyph itself, and SHALL NOT mirror content the product gives as an image or a view of its own rather than by a glyph's name.
TAB-33 THE tabs SHALL lay out a tab's label, in both of its copies (TAB-24) as text of its own direction, isolated from the text around it: each takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right word, a number with its sign or unit, a telephone number and an address keep their own order in a right-to-left layout and reorder nothing beside them; THE tabs SHALL still place each label and align its lines by the layout direction, not by the text's own, and SHALL cut a label that does not fit (TAB-14) at the end of its own direction, so that a left-to-right label in a right-to-left layout loses its right end and never its beginning.
TAB-34 THE tabs SHALL hold the active indicator the same distance, comp.tabs.track.padding, from the track's top, from its bottom and, on the first and the last tab, from the track's end: at every height the row takes (comp.tabs.container.height.small, comp.tabs.container.height.medium, comp.tabs.container.height-with-icon, or taller with larger text), whether the tabs hug or fill, and whether the indicator is a capsule or a circle; so that wherever the track's corner radius less comp.tabs.track.padding is the indicator's corner radius — at every height while comp.tabs.track.shape and the variant's active-indicator shape are both full, as in the default system — the indicator's end and the track's end are concentric curves. WHERE the first or the last tab is wider than its active indicator (a circle in a tab widened to the minimum target) THE tabs SHALL let that tab reach into the track's inset on that side by half the difference, as far as the track's edge, rather than hold the indicator further in; WHERE half the difference is more than the inset the tab SHALL end at the track's edge in layout and its hit area SHALL extend beyond the track's end as far as the minimum target needs, without affecting layout. A tab's container and its focus ring are drawn in the indicator's box and SHALL lie in the same place; WHILE the tabs scroll inside the track the distance SHALL hold at each end once the row is scrolled to that end. All of this SHALL hold whether the track is drawn or not (TAB-49).
TAB-35 WHILE fullWidth is false and every tab of the row draws its icon alone (TAB-19) THE tabs SHALL draw every tab's place — the active indicator on the selected tab, the container of each other tab — as a circle of one diameter, the row's height less comp.tabs.track.padding above and below, with the tab's glyph, the icon size of the row's size across (TAB-18), at its center; SHALL set neighbouring circles' centers one tab's width apart, every tab being as wide as the larger of the circle and the minimum target of TAB-5; SHALL make the track as wide as that leaves it and no wider, the first and the last circle concentric with its ends (TAB-34), so that the row is symmetric about its middle and, but for the thumb's place and the glyphs, the same whichever tab is selected; SHALL keep the active indicator that circle, neither wider nor narrower, in every frame while it slides or is dragged from one tab to another (TAB-17, TAB-28); and SHALL give the tabs hit areas that meet halfway between neighbouring circles and reach the track's top, its bottom and, on the first and the last tab, its end, so that every point of the track selects the tab whose circle is nearest and every hit area meets the minimum target. A row of two such tabs stays a row of tabs: each is exposed as a tab with its label as its name, and activating the selected one changes nothing.
TAB-36 WHILE group is not empty THE tabs SHALL be a member of the group of that name, together with every other row of tabs in the same document or window whose group is the same string, compared exactly, whether the product placed that row or a Tab view drew it (TBV-16); a group SHALL hold one choice, the value of a tab, or none, and SHALL hold none until TAB-37 or TAB-39 gives it one. WHILE its group holds a choice and the tabs has an enabled tab with that value THE tabs SHALL have that tab selected: it SHALL set value to the choice and emit nothing, in the first frame it is drawn, in the frame the choice changes, and in the frame it gains such a tab, has such a tab enabled, or is given the group. WHILE its group holds no choice, or the tabs has no enabled tab with the choice's value, THE tabs SHALL keep its own value and select as TAB-11 says, SHALL report nothing, and SHALL follow the group again as soon as the choice is the value of one of its enabled tabs. WHILE group is empty THE tabs SHALL share its choice with no other row.
TAB-37 WHEN a person selects a tab on a row that is a member of a group (TAB-1, TAB-12, TAB-29), or the product sets value on such a row, after the row has first been drawn, to the value of one of its enabled tabs, THE tabs SHALL make that value its group's choice in the same frame, so that every member follows as TAB-36 says. THE row the person selected on SHALL emit change exactly once, as it does without a group, and every other member SHALL emit nothing, so that each choice a person makes is announced once, by the row it was made on, however many rows follow it; a value set by the product SHALL emit nothing on any member. The value a row has when it is first drawn SHALL NOT become the group's choice, nor SHALL a value that names none of the row's tabs or names a disabled one; a drag across a row before its release (TAB-28) and indicatorOffset SHALL NOT be shared, and the other members wait for the selection.
TAB-38 WHEN a group's choice gives a row another selected tab and that row is neither the one a person selected on nor the one the product set (TAB-37), or the choice is one restored from storage (TAB-39), THE tabs SHALL draw that tab as selected at once: the active indicator and the underline on it without the slide of TAB-17, its colors without the motion of TAB-23, the tab brought into view inside the track at once (TAB-15), and its own panel changed at once (TAB-13). THE tabs SHALL NOT move keyboard focus to that row or within it, SHALL NOT show a hovered, focused or pressed state for the change, SHALL NOT scroll anything outside its own track to bring itself into view, and SHALL announce nothing. THE row a person selected on SHALL answer the press, slide and keep keyboard focus as it does without a group, and a row whose value the product set SHALL slide as TAB-17 says. Every member SHALL draw the new choice in the same frame: no frame SHALL show one member with the new choice and another member, which has an enabled tab of that value, with the old one. WHERE a member follows a drag of its own when the choice changes THE tabs SHALL cancel that drag as TAB-30 says for a value the product sets.
TAB-39 WHILE rememberAs is not empty THE tabs SHALL keep the person's choice on their device: WHEN a person's selection gives the row another selected tab, made on the row itself or, through its group, on another member (TAB-37), THE tabs SHALL store that tab's value, and nothing else, under exactly the key rememberAs in the storage the platform keeps for the product on the device across pages and visits, once for each key however many members name it; a value set by the product and a choice restored from storage SHALL be stored by no row. WHEN the row is first drawn while a value is stored under its key THE tabs SHALL restore it before that first frame: WHILE the row is a member of a group that holds no choice the stored value SHALL become the group's choice, whether or not this row has a tab with it (a group that already holds a choice keeps it), and WHILE the row has no group the row SHALL select its enabled tab with that value where it has one; no frame SHALL show the row with another tab selected, or another panel, first, and the row SHALL emit nothing. THE tabs SHALL use a stored value only to compare it with its tabs' values, and SHALL NOT draw it, announce it or run it. WHILE rememberAs is empty THE tabs SHALL neither read nor write any storage. IF the storage is missing, blocked or full, or reading or writing it fails, THEN THE tabs SHALL behave as though nothing were stored, keep the choice for as long as the document or window lasts, and raise and report nothing.
TAB-40 WHEN a person selects a tab on a row that is a member of a group and, in following that choice, other members change the size of what lies before the row in a region that scrolls, THE tabs SHALL keep the row where the person is using it: in the frame that first draws the new choice the row SHALL be at the place in the window that it had in the frame before, the scroll positions of the regions it scrolls in having been moved by exactly the distance the row would otherwise have moved, at once, with no scrolling motion and with no frame in between that shows the row elsewhere; WHERE a region cannot scroll that far THE tabs SHALL move it as far as it goes. THE tabs SHALL make no such correction for a choice the product set or one restored from storage, where no person has a place to keep, and SHALL leave what lies after the row to move as its own panel's and the other members' new heights require.
TAB-41 WHILE many rows are members of one group (a page of sixty code samples) THE tabs SHALL do the work of a choice once for the group and not once for each row: storage SHALL be read at most once for each key in the life of a document or window and written at most once for each key and choice (TAB-39), the place of the row acted on SHALL be measured and corrected once (TAB-40), and a member that follows SHALL run no motion (TAB-38). WHEN a member is removed THE tabs SHALL take it out of its group, and the group SHALL keep its choice for as long as the document or window lasts, with or without members, so that a row drawn later (a view navigated to without a new document) takes it in its first frame.
TAB-42 WHILE the row has no panel — none of its own, none of a Tab view that draws it or is linked to it (TAB-13), and none of a component that draws the row together with the panel it switches (the date-time picker's entry-mode tabs, DTM-13) — THE tabs SHALL expose the row as a single choice and not as a tab list: the radio group role named by the row's label, and each tab with the radio role, its checked state (the selected tab checked, every other tab not), its disabled state, its position and the number of tabs, named by its own label whether or not that label is drawn; THE tabs SHALL expose no tab list, tab or tab panel role, SHALL associate no tab with any element as the thing it controls, and SHALL show and hide nothing itself. THE tabs SHALL keep the row one Tab stop, the selected tab (TAB-12), Tab moving focus from it to whatever follows the row. WHILE a tab of such a row has keyboard focus, in either orientation, Right Arrow and Down Arrow SHALL move focus to the next enabled tab and Left Arrow and Up Arrow to the previous one, wrapping at the ends, with Right Arrow and Left Arrow swapped in a right-to-left layout, and Home and End SHALL move it to the first and the last enabled tab; each move SHALL select the tab it reaches and emit change, as the arrows of a radio group check the radio they reach, and Enter and Space SHALL select the focused tab (TAB-1). A selection SHALL be made, announced and emitted exactly as on a row with panels: the checked state is what assistive technology is told, and what the choice changes below the row is the product's to draw on change. WHEN the row gains its first panel or loses its last THE tabs SHALL change what they expose to match, at once, without changing the selection, keyboard focus or anything drawn. WHERE a row has a panel for some of its tabs and not for others it is a tab list (TAB-13): THE tabs SHALL associate each tab that has one with it and SHALL show no panel while a tab without one is selected.
TAB-43 THE tabs SHALL draw a new selection as the row's transition names: the name transition gives, or, WHILE transition is auto, the name comp.tabs.motion.transition holds (slide or none); WHEN transition names a value the tabs do not define THE tabs SHALL treat it as auto and in debug builds report it. WHILE the row's transition is slide, TAB-15, TAB-17, TAB-23 and TAB-28 to TAB-30 hold as written. WHILE it is none THE tabs SHALL draw a new selection whole in the frame it is made, whatever made it — a press, a key, the release of a drag, the product, or the row's group: the active indicator and the underline on the newly selected tab at their full size and gone from the tab that was selected, each of the two tabs' labels and icons in the colors of its new look, and the tab brought into view inside the track (TAB-15), with no slide, no resize, no fade and no frame that shows a look in between. THE tabs SHALL still take a drag across the row (TAB-28) and still draw the active indicator where indicatorOffset puts it (TAB-26), each in the frame the pointer or the offset moves, since the person's own finger moves it and no motion is the component's; they SHALL take the drag without the catch-up motion of TAB-28 and SHALL put the active indicator and the underline on their tab at once when the drag is released or cancelled (TAB-29, TAB-30), as TAB-30 says where motion is reduced. A tab's hover, focus and press SHALL be answered as under slide: the state's colors over comp.tabs.motion.state-duration (TAB-23), the press as sys.behavior.pressed-feedback names and held for sys.motion.duration.press-hold (TAB-8, TAB-9), the focus indicator without animation (TAB-4); WHERE the newly selected tab is still hovered, focused or pressed it SHALL be drawn in that state of the selected look. WHERE motion is reduced THE tabs SHALL draw a new selection as none does whatever transition names. Changing transition SHALL NOT change the selected tab, keyboard focus or what the row emits, and a slide that is running when the row's transition becomes none SHALL end on its tab at once.
TAB-44 THE tabs SHALL draw the row at its size: every tab at least the container height of that size tall (comp.tabs.container.height.medium for medium, comp.tabs.container.height.small for small), with the tab padding (comp.tabs.container.padding-inline.medium for medium, comp.tabs.container.padding-inline.small for small), the active indicator's margins (comp.tabs.active-indicator.padding-inline.medium for medium, comp.tabs.active-indicator.padding-inline.small for small; comp.tabs.active-indicator.padding-block.medium for medium, comp.tabs.active-indicator.padding-block.small for small) and the icon size (comp.tabs.icon.size.medium for medium, comp.tabs.icon.size.small for small) of that size. Everything else SHALL be the same at every size: the label's text style (comp.tabs.label.typescale), the gap, the track's inset and corners (comp.tabs.track.padding, comp.tabs.track.shape), every color and every motion, what assistive technology is told and the keyboard model. The heights are minimums (TAB-18): with larger text a small row grows as a medium one does, cuts nothing, and scrolls its tabs inside the track rather than the page (TAB-14). WHILE size is small THE tabs SHALL place a tab's icon before its label in every variant (TAB-18) and SHALL meet the minimum target by its hit area (TAB-5). Size, variant, track and transition are independent: each variant SHALL be drawn at each size, and changing size SHALL NOT change the selected tab, keyboard focus or what is scrolled into view.
TAB-45 WHERE the platform draws with a person's own forced colors (the web's forced-colors mode, and a platform's equivalent where it has one) THE tabs SHALL draw the active indicator as a filled, opaque shape in the platform's highlight color with the selected tab's label and icon in its highlighted-text color, every other tab's label and icon in its text color, a disabled tab's in its color for disabled text and a disabled selected tab's indicator in that same color, the underline in the highlight color and the divider in the text color, and the focus indicator in the platform's own colors, in every variant alike and with or without the track. THE tabs SHALL NOT rely on the track, on a state layer, on a translucent fill or on a difference between two of the system's own colors to say which tab is selected there: the filled indicator SHALL remain, so that a tonal row without a track, whose indicator is a faint layer in the system's own colors, shows its selection as plainly as a primary one.
TAB-49 THE tabs SHALL fill the track (TAB-27) while the row's track is on and SHALL fill nothing behind the row while it is off: on or off as track says while it is true or false, and, WHILE track is null, as comp.tabs.track.visible holds (on in the default system). WHILE the track is off the surface the row is placed on SHALL show between and around the tabs, and THE tabs SHALL change nothing else: the row's size, the inset the track has (comp.tabs.track.padding) above, below and at each end, the place and size of every tab, container, active indicator, underline and focus indicator (TAB-4, TAB-34), the corners that clip a row that scrolls (comp.tabs.track.shape, TAB-14), the hit areas, and every color of the row's variant, so that turning the track on or off moves nothing and two rows that differ only in it line up. THE tabs SHALL draw every part in its own color over whatever lies beneath it — the track, or with no track whatever is behind the row — and SHALL NOT assume a surface: a color that is translucent (a state layer, the default system's tonal active indicator) is laid over what is there. The track is decoration, drawn or not, and nothing a person is told changes with it. Track, variant, size and transition are independent: each variant SHALL be drawn with and without its track at each size, and changing track SHALL be drawn at once, without a motion of its own, and SHALL NOT change the selected tab, keyboard focus, what is scrolled into view or what the row emits.
TAB-50 THE tabs SHALL draw the three variants alike in everything but their tokens: the same parts in the same places, the same sizes, the same keyboard model, and the same roles, names and states told to assistive technology; a variant says how much the selection weighs, not what the row is. Each variant SHALL read its own parts (comp.tabs.primary.selected.active-indicator.color, comp.tabs.secondary.selected.active-indicator.color, comp.tabs.tonal.selected.active-indicator.color, and so on for every part TAB-3, TAB-6, TAB-11 and TAB-24 name). WHERE an active indicator's color is translucent — the default system's tonal indicator is a layer of the selected tab's content color — THE tabs SHALL draw it once, at that opacity, over the track or the surface beneath it, with the selected tab's state layer (TAB-18) over it; SHALL NOT let two drawings of it overlap and darken while it slides, resizes or is dragged; and SHALL draw the covered label and icon (TAB-24) over it at their own opacity. A tonal tab's label and icon are comp.tabs.tonal.label.color and comp.tabs.tonal.icon.color where the indicator does not cover them and comp.tabs.tonal.selected.covered-label.color and comp.tabs.tonal.selected.covered-icon.color on the selected tab once the indicator has landed, so that in the default system a tonal row tells its selection by the layer and by the darker label together. THE selected tab's label SHALL be drawn, in both of its copies, at the weight of comp.tabs.label.typescale plus comp.tabs.primary.selected.label.emphasis, comp.tabs.secondary.selected.label.emphasis or comp.tabs.tonal.selected.label.emphasis by variant, so that a selection drawn by a faint indicator is also told by something other than color; every other tab's label SHALL keep the typescale's weight, and every tab SHALL keep the width its label needs at the heavier weight whether it is selected or not, so that nothing in the row moves or wraps when the selection does. The weight SHALL change with the label's color (TAB-23), at once where the colors do. A tonal tab with an icon and a drawn label SHALL place the icon before the label at every size (TAB-18).
TAB-51 WHILE orientation is vertical THE tabs SHALL stack the tabs from top to bottom in the order given, in a column at the start edge of the row beside its panels, every tab as wide as the column: the widest tab's content plus the padding of the row's size on each side (comp.tabs.container.padding-inline.medium for medium, comp.tabs.container.padding-inline.small for small; TAB-44), or the width the product gives the tab list where that is wider, and never wider than the space the row has. Each tab's icon SHALL stand before its label in every variant and at every size, comp.tabs.container.gap apart, both at the tab's start edge, and each tab SHALL be at least the container height of the row's size tall and, because the tabs touch their neighbours above and below, at least the minimum target tall in layout (sys.target.touch for touch input, sys.target.pointer for fine pointers; TAB-5). TAB-51 to TAB-61 SHALL hold in place of what TAB-5, TAB-14, TAB-16, TAB-18, TAB-20, TAB-27, TAB-28, TAB-34 and TAB-35 say of a horizontal row's widths, heights, insets and edges; every other behaviour holds in both orientations.
TAB-52 WHILE orientation is vertical THE tabs SHALL draw the selected tab's active indicator, and every tab's container (TAB-18), as the tab's box less comp.tabs.track.padding on every side: as wide as the column less the inset on each side and as tall as the tab less the inset above and below, whatever the tab holds, never a circle, its corners rounded by the variant's active-indicator shape, each at most half its height (TAB-16); and SHALL slide and resize it from one tab to another along the column over comp.tabs.motion.indicator-duration with comp.tabs.motion.indicator-easing, as TAB-17 says, the covered label and icon following it as TAB-24 says.
TAB-53 WHILE orientation is vertical THE tabs SHALL draw the divider (TAB-20) as a line of comp.tabs.divider.thickness in comp.tabs.divider.color along the column's end edge, the side of the panels, the full height of the column, and the selected tab's underline (TAB-16) as a line of the variant's underline thickness on that edge, above the divider and as tall as the selected tab in every variant, its corners that face away from the divider rounded by the variant's underline shape; in a right-to-left layout both SHALL lie on the column's left edge.
TAB-54 WHILE orientation is vertical, the row has a panel and a tab has keyboard focus, Down Arrow and Up Arrow SHALL move focus to the next and previous enabled tab, wrapping at the ends, and Home and End to the first and last enabled tab, each move also selecting the tab it reaches and emitting change (automatic activation, TAB-12); Right Arrow and Left Arrow SHALL do nothing, in either layout direction, and are left to the page. A column without a panel moves by every arrow key, as TAB-42 says.
TAB-55 WHILE orientation is vertical and the row has a panel THE tabs SHALL expose the tab list as vertical (aria-orientation on the web, the platform's equivalent where it has one) and every other role, name, state and relation as TAB-13 says; WHILE it is horizontal THE tabs SHALL expose the tab list's orientation as the platform's default for a tab list, which is horizontal. A row without a panel (TAB-42) SHALL expose no orientation in either case, because its arrow keys do not depend on it.
TAB-56 WHILE orientation is vertical and the tabs are taller than the space the row has, THE tabs SHALL scroll them vertically inside the track, keeping the track still and the tabs clipped to its shape (TAB-27), and SHALL bring a tab into view along the column as TAB-15 says; WHILE orientation is vertical fullWidth SHALL be ignored, and WHERE it is true a debug build SHALL report it.
TAB-57 WHILE orientation is vertical THE tabs SHALL take a drag (TAB-28 to TAB-30) only from a mouse or a pen moved past the slop further up or down than sideways, carrying the active indicator and the underline along the column as TAB-28 says along the row, and SHALL leave every touch to scroll the tabs or whatever holds the row, and every drag that goes sideways first to the page.
TAB-58 WHILE orientation is vertical THE tabs SHALL round each corner of the track by the smaller of comp.tabs.track.shape and the active indicator's corner radius (the variant's active-indicator shape, at most half the indicator's height) plus comp.tabs.track.padding, so that the track's corners are concentric with the indicator's on the first and the last tab however tall the column is.
TAB-59 WHILE orientation is vertical and the row is linked to a Tab view (TBV-17) THE tabs SHALL draw indicatorOffset (TAB-26) along the column, between the selected tab and the enabled tab below it (above 0) or above it (below 0); the row a Tab view draws itself (TBV-16) is horizontal.
TAB-60 WHEN orientation names a value the tabs do not define THE tabs SHALL lay the tabs out horizontally, and in debug builds report the value.
TAB-61 WHILE orientation is vertical and a tab's label is wider than its tab less the padding of the row's size on each side (and, beside an icon, less the icon and comp.tabs.container.gap) — a column narrower than its widest tab — THE tabs SHALL truncate the label at the end of its own direction (TAB-33) with an ellipsis, on one line, and SHALL show it whole as TAB-25 says.
TAB-62 WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the row's host, by the attribute or by the ARIA property that reflects it, THE tabs SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on the element that carries the row's role (its tab list, or its radio group while the row has no panel) — an id reference by the elements it names in the host's tree or a tree around it. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE tabs SHALL take it off that element. A tab's host carries the tab's role itself, so what the product writes on it is not moved, but for a name: WHILE a tab's label is not empty THE tabs SHALL hold the product's aria-label and aria-labelledby on that host as their data-aria- twins, so that label is the tab's whole name although the platform gives aria-labelledby the precedence (TAB-22), and WHEN label is emptied SHALL give them back as they were written; WHEN the product removes one, or its data-aria- twin, or sets the ARIA property that reflects it to null, THE tabs SHALL take it away.

Platform name mapping

Parity does not mean identical APIs. Each platform uses its own idiom for the same contract.

Spec Web SwiftUI Jetpack Compose Flutter React Native
Component tanrei-tabs TanreiTabs TanreiTabs TanreiTabBar Tabs
variant · Property variant variant variant variant variant
size · Property size size size size size
track · Property track track track track track
fullWidth · Property fullWidth fullWidth fullWidth isScrollable fullWidth
orientation · Property orientation orientation orientation direction orientation
value · Property value selection selectedValue value value
indicatorOffset · Property indicatorOffset link link link link
transition · Property transition transition transition transition transition
label · Property label label contentDescription semanticLabel accessibilityLabel
group · Property group selection selectedValue value value
rememberAs · Property rememberAs selection selectedValue value value
change · Event change selection onSelectedValueChange onChanged onValueChange
Notes tanrei-tabs (variant, full-width, value, label) holds tanrei-tab children and, after them, tanrei-tab-panel children matched to tabs by their value attribute, or is drawn by a tanrei-tab-view as its own row, or is named by a linked tanrei-tab-view (tabs="<id>") whose tanrei-tab-panel children take their place. indicatorOffset is a property only, with no attribute: the Tab view writes it in every frame of a drag. value names the selected tab, as a <select>'s does. It sets role tablist, tab and tabpanel, aria-selected, aria-controls and aria-labelledby (radiogroup and radio with aria-checked on a row without panels, TAB-42), hides the panels that are not selected, and gives a panel with no focusable content tabindex 0. It sets its own value before firing change, as a native control does. The covered copy of a label and icon (TAB-24) is an aria-hidden layer clipped with clip-path to the indicator's box, animated with it; a truncated label (text-overflow: ellipsis) shows the whole label in a plain tooltip (TAB-25) without aria-describedby, since it is already the name. The track is the row's own background, and the tabs scroll inside it. A drag (TAB-28 to TAB-30) is followed with Pointer Events: pointerdown on the row, setPointerCapture once the drag is taken, pointermove placing the indicator, pointerup choosing the tab, and pointercancel, lostpointercapture or Escape cancelling; the click that follows a taken drag is swallowed, and aria-selected is written only on the release. The in-between look needs no second mechanism: the indicator's inset and width are the same custom properties a slide transitions, written in each frame of a drag, and the covered copy's clip-path follows them, so a drag and a slide draw the same frames. touch-action: pan-y on a row that does not scroll (and on the selected tab of one that does) leaves sideways drags to the row and vertical ones to the page. A shared choice: group and remember-as are attributes with the properties group and rememberAs. A group is the tanrei-tabs of one document that name it, those inside shadow trees and those a tanrei-tab-view drew included; an iframe is another document. The members and the choice are kept by the module, in a map from the name, and never in the DOM. remember-as is a key of the origin's localStorage, read synchronously before the row's first render and at most once for each key, every access inside try, so a browser that blocks storage or throws on it costs nothing but the memory; a key is the product's to namespace, since localStorage is shared by everything on the origin. A member that follows is written (value, aria-selected, its panels' hidden) with its slide and its color transition off for that frame. For TAB-40 the row acted on reads its getBoundingClientRect().top before the group is written and once more after, and moves scrollTop of its nearest scrolling ancestor (the document's scrolling element where none is nearer) by the difference with behavior: 'instant'; a browser that anchors scrolling itself has by then moved some or all of the way, and the row corrects only what is left, so the two never add up. change bubbles, so one listener on the document hears each choice once and reads the group from event.target.group. The first frame of TAB-39 is the first the element draws: before tanrei-tabs is defined the element draws nothing of its own, and a product that shows server-rendered rows before then guards that as it guards any element not yet defined. The value tonal of variant; size is an attribute and a property (small, medium); both axes are open registries (TanreiTabsVariantRegistry, TanreiTabsSizeRegistry in registry.ts, in the Button's form), and each size's five tokens are read through the same private custom properties, set by a block per size as a variant's are. A small row's tab keeps its 32-high box and carries the hit area of TAB-5 as an inset pseudo-element that reaches above and below it, under any interactive content it overlaps; the row itself is not made taller. track is a property that is true, false or null: the attribute is absent for null, present for true and ="false" for false, as the tooltip's arrow is; null reads --tanrei-comp-tabs-track-visible from the cascade by a style query, as a separation is read, and off sets the track's fill to none and nothing else. transition is an attribute and a property (auto, slide, none); auto reads --tanrei-comp-tabs-motion-transition, in the stylesheet by a style query and once in the script for the scroll and the settle after a drag; under none the indicator's and the cover's transitions and the selected look's color transition are off, scrollIntoView uses behavior: 'instant', and the drag is taken as it is under prefers-reduced-motion. The tonal indicator is one element with a translucent background, so its slide and resize never overlap two drawings of it (TAB-50). A row without panels (TAB-42) has role radiogroup where a row with panels has role tablist, with no aria-orientation, and each tanrei-tab role radio with aria-checked in place of aria-selected and no aria-controls. The row has a panel while it holds a tanrei-tab-panel, while the tanrei-tab-view that draws it or is linked to it has a panel for one of its tabs, or while a Tanrei element that draws the row links its own panel to it (the date-time picker links its time section, DTM-13); the roles follow in the update that re-reads the panels. The roles are read from the panels and never set by the product, so a row cannot claim panels it does not have. TanreiTabs(selection: Binding<V>, variant:, fullWidth:) { TanreiTab … }: each tab carries its panel, and only the selected one is shown. The binding holds the selected tab's tag; writing it is change. label sets the row's accessibilityLabel. A truncated label (.lineLimit(1), .truncationMode(.tail)) is shown whole by the large-content viewer and by .help on macOS (TAB-25). SwiftUI's TabView is an app's tab bar (a navigation bar), not this component. Until it catches up it ships scrollable: instead of fullWidth:, a line indicator and wrapping fixed labels. link is the shared TanreiTabLink a linked Tab view is passed too: the tabs read the indicator offset (TAB-26) from it and report their order and disabled tabs to the Tab view through it; without a Tab view it is omitted and the offset is 0. The drag is a DragGesture(minimumDistance:) on the row, simultaneousGesture with the tabs' taps, its onChanged placing the indicator and its onEnded reading predictedEndTranslation for the fling; the binding is written only in onEnded. Until it catches up it also draws no track and follows no drag. A shared choice has no parameter here: rows given the same Binding show the same choice, which is the group (TAB-36), and SwiftUI redraws them in one frame. The product keeps a row that lacks a value on a binding of its own, since a selection that names none of a row's tabs selects none (TAB-11), and remembers by backing the binding with @AppStorage (or @SceneStorage), which is restored before the first frame. TAB-37 to TAB-41 describe a group the component keeps, which this platform does not have: every row slides as TAB-17 says for a value the product sets, since the row cannot tell following from being set; the README says so, and says that a ScrollView does not hold a row in place when content above it changes height. Not built on this platform yet: the selected label's added weight from selected.label.emphasis with every tab keeping the heavier label's width (TAB-16, TAB-50); the selected tab's state layer over its active indicator (TAB-18); the icon's place read from icon.placement of the row's size (TAB-18); variant: .tonal; size: TanreiTabsSize (.small, .medium); track: Bool? (nil reads the generated choice trackVisible); transition: TanreiTabsTransition (.auto, .slide, .none; .auto reads the generated choice), under .none the selection is set without withAnimation and the DragGesture stays. The hit area of a small row is .contentShape on a frame of the minimum target. A row whose tabs carry no panel is the single choice of TAB-42: a container named by label without the tab bar trait, each tab a button with the selected trait, as a segmented picker is told; until it catches up every row has the tab bar trait. TanreiTabs(selectedValue, onSelectedValueChange, variant, fullWidth, contentDescription) { TanreiTab(…) }, one composable for rows that hug and rows that fill (Material's PrimaryTabRow, SecondaryTabRow and ScrollableTabRow in one). State hoisting: the caller keeps selectedValue and moves it from onSelectedValueChange. A truncated label (maxLines = 1, TextOverflow.Ellipsis) is shown whole in a plain tooltip on long press and hover (TAB-25). Compose has no tab panel role: the product shows the selected tab's content below the row. Until it catches up it ships scrollable instead of fullWidth, a line indicator and wrapping fixed labels. link is the shared rememberTanreiTabLink() a linked Tab view is passed too: the tabs read the indicator offset (TAB-26) from it and report their order and disabled tabs to the Tab view through it; without a Tab view it is omitted and the offset is 0. The drag is Modifier.pointerInput with detectHorizontalDragGestures (or draggable with a DraggableState) on the row, a VelocityTracker giving the fling; onSelectedValueChange is called only when the drag ends. Until it catches up it also draws no track and follows no drag. A shared choice has no parameter here: rows given the same hoisted selectedValue show the same choice, which is the group (TAB-36), and recompose in one frame. The caller gives a row that lacks a value a state of its own, since a selection that names none of a row's tabs selects none (TAB-11), and remembers by keeping the state in DataStore or SharedPreferences, read before the first composition. TAB-37 to TAB-41 describe a group the component keeps, which this platform does not have: every row slides as TAB-17 says for a value the product sets, since the row cannot tell following from being set; the README says so, and says that a LazyColumn keeps its first visible item in place when items above change height while a scrolling Column does not. Not built on this platform yet: the selected label's added weight from selected.label.emphasis with every tab keeping the heavier label's width (TAB-16, TAB-50); the selected tab's state layer over its active indicator (TAB-18); the icon's place read from icon.placement of the row's size (TAB-18); TanreiTabsVariant.Tonal; size: TanreiTabsSize (Small, Medium); track: Boolean? = null (null reads the generated choice trackVisible); transition: TanreiTabsTransition (Auto, Slide, None), under None the indicator is placed with snapTo in place of animateTo and the drag is still detected. The hit area of a small row is Modifier.minimumInteractiveComponentSize() outside the drawn box. The row holds no panel here, since the product draws the content below it, so it is the single choice of TAB-42 until a Tab view links one: a selectableGroup() named by contentDescription, each tab with Role.RadioButton and its selected state; until it catches up every tab has Role.Tab. TanreiTabBar<T>(value:, onChanged:, variant:, isScrollable:, semanticLabel:, tabs: [TanreiTab<T>(…)]), as TabBar but selected by value rather than by a TabController's index; the caller moves value from onChanged. isScrollable is the inverse of fullWidth, as in Flutter's TabBar, and defaults to true (hug). A truncated label (maxLines: 1, TextOverflow.ellipsis) is shown whole in a Tooltip (TAB-25). TanreiTabBarView gives way to the Tab view, TanreiTabView, which draws a TanreiTabBar itself or is linked by link:, a TanreiTabLink<T> passed to both, as Flutter's TabController is shared by a TabBar and a TabBarView: the bar reads the indicator offset (TAB-26) from it and reports its order and disabled tabs to the view through it. Until it catches up, TanreiTabBarView<T>(value:, children:, semanticLabels:) shows the panel of the selected value and does not swipe. Until it catches up it ships isScrollable defaulting to false, a line indicator and wrapping fixed labels. The drag is a GestureDetector's onHorizontalDragStart, onHorizontalDragUpdate and onHorizontalDragEnd on the bar, DragEndDetails.primaryVelocity giving the fling; onChanged is called only from onHorizontalDragEnd. Until it catches up it also draws no track and follows no drag. A shared choice has no parameter here: bars given the same value from one piece of the product's state show the same choice, which is the group (TAB-36), and rebuild in one frame. The product gives a bar that lacks a value a state of its own, since a selection that names none of a bar's tabs selects none (TAB-11), and remembers with shared_preferences or its own store, read before the first build. TAB-37 to TAB-41 describe a group the component keeps, which this platform does not have: every row slides as TAB-17 says for a value the product sets, since the row cannot tell following from being set; the README says so, and says that a scroll view does not hold a bar in place when content above it changes height. Not built on this platform yet: the selected label's added weight from selected.label.emphasis with every tab keeping the heavier label's width (TAB-16, TAB-50); the selected tab's state layer over its active indicator (TAB-18); the icon's place read from icon.placement of the row's size (TAB-18); TanreiTabsVariant.tonal; size: TanreiTabsSize (small, medium); bool? track (null reads the generated choice trackVisible); transition: TanreiTabsTransition (auto, slide, none), under none the controller's value is set without animateTo and the horizontal drag is still recognised. The hit area of a small row is a GestureDetector box of the minimum target with HitTestBehavior.translucent. A row with no linked TanreiTabBarView holds no panel and is the single choice of TAB-42: each tab is a radio, Semantics(checked:, inMutuallyExclusiveGroup: true), in place of SemanticsRole.tab and selected; until it catches up every tab has SemanticsRole.tab. <Tabs items={Tab[]} value onValueChange variant fullWidth accessibilityLabel>; controlled, the caller moves value from onValueChange and renders the selected panel below the row. React Native has no tab panel role, and the row holds no panel (the caller renders it), so the row is the single choice of TAB-42: the radiogroup role, and each tab the radio role with its checked state. Until it catches up the row has the tablist role and each tab the tab role with its selected state, and the selected panel follows the row in reading order. A truncated label (numberOfLines={1}, ellipsizeMode="tail") keeps the whole label as the tab's accessibilityLabel; React Native has no tooltip of its own, so it is not shown whole elsewhere (TAB-25: where the platform has an idiom). Until it catches up it ships scrollable instead of fullWidth, a line indicator and wrapping fixed labels. link is the shared useTabLink() a linked Tab view is passed too: the tabs read the indicator offset (TAB-26) from it and report their order and disabled tabs to the Tab view through it; without a Tab view it is omitted and the offset is 0. The drag is a PanResponder on the row (or React Native Gesture Handler's Gesture.Pan() where the product has it), claiming the responder once the drag passes the slop sideways, with the release's vx giving the fling; onValueChange is called only on release. Until it catches up it also draws no track and follows no drag. A shared choice has no prop here: rows given the same controlled value from one piece of the product's state show the same choice, which is the group (TAB-36), and render in one commit. The product gives a row that lacks a value a state of its own, since a selection that names none of a row's tabs selects none (TAB-11), and remembers with AsyncStorage or its own store; AsyncStorage answers after the first frame, so a product that must not show another tab first reads it before it mounts the screen. TAB-37 to TAB-41 describe a group the component keeps, which this platform does not have: every row slides as TAB-17 says for a value the product sets, since the row cannot tell following from being set; the README says so, and says that a ScrollView holds a row in place only with maintainVisibleContentPosition. Not built on this platform yet: the selected label's added weight from selected.label.emphasis with every tab keeping the heavier label's width (TAB-16, TAB-50); the selected tab's state layer over its active indicator (TAB-18); the icon's place read from icon.placement of the row's size (TAB-18); variant="tonal"; size ("small", "medium"); track?: boolean | null (null reads the generated choice trackVisible); transition ("auto", "slide", "none"), under "none" the indicator's value is set with setValue in place of Animated.timing and the PanResponder stays. The hit area of a small row is hitSlop.

Tokens

Token Aliases Resolved (light)
--tanrei-comp-tabs-active-indicator-height — 32px
--tanrei-comp-tabs-active-indicator-padding-block-medium sys.space.space50 4px
--tanrei-comp-tabs-active-indicator-padding-block-small sys.space.space25 2px
--tanrei-comp-tabs-active-indicator-padding-inline-medium comp.tabs.container.padding-inline.medium 16px
--tanrei-comp-tabs-active-indicator-padding-inline-small comp.tabs.container.padding-inline.small 12px
--tanrei-comp-tabs-container-gap sys.space.space50 4px
--tanrei-comp-tabs-container-height-with-icon — 64px
--tanrei-comp-tabs-container-height-medium — 48px
--tanrei-comp-tabs-container-height-small — 32px
--tanrei-comp-tabs-container-padding-inline-medium sys.space.space200 16px
--tanrei-comp-tabs-container-padding-inline-small sys.space.space150 12px
--tanrei-comp-tabs-container-shape sys.shape.corner.full 9999px
--tanrei-comp-tabs-disabled-opacity — 1
--tanrei-comp-tabs-divider-color — #000000 · 0%
--tanrei-comp-tabs-divider-thickness sys.border.hairline 1px
--tanrei-comp-tabs-focused-opacity — 1
--tanrei-comp-tabs-hovered-opacity — 1
--tanrei-comp-tabs-icon-placement-medium — 0
--tanrei-comp-tabs-icon-placement-small — 1
--tanrei-comp-tabs-icon-size-medium sys.icon.medium 24px
--tanrei-comp-tabs-icon-size-small sys.icon.small 20px
--tanrei-comp-tabs-motion-feedback-duration sys.motion.role.feedback 450ms
--tanrei-comp-tabs-motion-indicator-duration sys.motion.role.selection.travel 250ms
--tanrei-comp-tabs-motion-indicator-easing sys.motion.easing.spatial cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-tabs-motion-state-duration sys.motion.role.state.large 250ms
--tanrei-comp-tabs-motion-state-easing sys.motion.easing.state cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-tabs-motion-transition — 0
--tanrei-comp-tabs-pressed-opacity — 1
--tanrei-comp-tabs-primary-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-primary-active-indicator-shape sys.shape.corner.full 9999px
--tanrei-comp-tabs-primary-active-indicator-thickness — 3px
--tanrei-comp-tabs-primary-container-color — #000000 · 0%
--tanrei-comp-tabs-primary-covered-icon-color sys.color.on-primary #ffffff
--tanrei-comp-tabs-primary-covered-label-color sys.color.on-primary #ffffff
--tanrei-comp-tabs-primary-disabled-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-primary-disabled-active-indicator-shape — 9999px
--tanrei-comp-tabs-primary-disabled-active-indicator-thickness — 3px
--tanrei-comp-tabs-primary-disabled-container-color — #000000 · 0%
--tanrei-comp-tabs-primary-disabled-covered-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-covered-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-selected-active-indicator-color — #1b1b1b · 12%
--tanrei-comp-tabs-primary-disabled-selected-container-color — #000000 · 0%
--tanrei-comp-tabs-primary-disabled-selected-covered-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-selected-covered-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-selected-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-selected-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-selected-state-layer-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-disabled-state-layer-color — #1b1b1b · 38%
--tanrei-comp-tabs-primary-disabled-track-color — #eeeeee
--tanrei-comp-tabs-primary-disabled-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-disabled-underline-shape — 3px
--tanrei-comp-tabs-primary-disabled-underline-thickness — 3px
--tanrei-comp-tabs-primary-focused-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-primary-focused-active-indicator-shape — 9999px
--tanrei-comp-tabs-primary-focused-active-indicator-thickness — 3px
--tanrei-comp-tabs-primary-focused-container-color — #262626 · 10%
--tanrei-comp-tabs-primary-focused-covered-icon-color — #ffffff
--tanrei-comp-tabs-primary-focused-covered-label-color — #ffffff
--tanrei-comp-tabs-primary-focused-icon-color — #262626
--tanrei-comp-tabs-primary-focused-label-color — #262626
--tanrei-comp-tabs-primary-focused-selected-active-indicator-color — #1b1b1b
--tanrei-comp-tabs-primary-focused-selected-container-color — #ffffff · 10%
--tanrei-comp-tabs-primary-focused-selected-covered-icon-color — #ffffff
--tanrei-comp-tabs-primary-focused-selected-covered-label-color — #ffffff
--tanrei-comp-tabs-primary-focused-selected-icon-color — #262626
--tanrei-comp-tabs-primary-focused-selected-label-color — #262626
--tanrei-comp-tabs-primary-focused-selected-state-layer-color — #ffffff
--tanrei-comp-tabs-primary-focused-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-focused-state-layer-color — #262626
--tanrei-comp-tabs-primary-focused-track-color — #eeeeee
--tanrei-comp-tabs-primary-focused-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-focused-underline-shape — 3px
--tanrei-comp-tabs-primary-focused-underline-thickness — 3px
--tanrei-comp-tabs-primary-hovered-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-primary-hovered-active-indicator-shape — 9999px
--tanrei-comp-tabs-primary-hovered-active-indicator-thickness — 3px
--tanrei-comp-tabs-primary-hovered-container-color — #262626 · 8%
--tanrei-comp-tabs-primary-hovered-covered-icon-color — #ffffff
--tanrei-comp-tabs-primary-hovered-covered-label-color — #ffffff
--tanrei-comp-tabs-primary-hovered-icon-color — #262626
--tanrei-comp-tabs-primary-hovered-label-color — #262626
--tanrei-comp-tabs-primary-hovered-selected-active-indicator-color — #1b1b1b
--tanrei-comp-tabs-primary-hovered-selected-container-color — #ffffff · 8%
--tanrei-comp-tabs-primary-hovered-selected-covered-icon-color — #ffffff
--tanrei-comp-tabs-primary-hovered-selected-covered-label-color — #ffffff
--tanrei-comp-tabs-primary-hovered-selected-icon-color — #262626
--tanrei-comp-tabs-primary-hovered-selected-label-color — #262626
--tanrei-comp-tabs-primary-hovered-selected-state-layer-color — #ffffff
--tanrei-comp-tabs-primary-hovered-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-hovered-state-layer-color — #262626
--tanrei-comp-tabs-primary-hovered-track-color — #eeeeee
--tanrei-comp-tabs-primary-hovered-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-hovered-underline-shape — 3px
--tanrei-comp-tabs-primary-hovered-underline-thickness — 3px
--tanrei-comp-tabs-primary-icon-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-primary-label-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-primary-pressed-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-primary-pressed-active-indicator-shape — 9999px
--tanrei-comp-tabs-primary-pressed-active-indicator-thickness — 3px
--tanrei-comp-tabs-primary-pressed-container-color — #262626 · 10%
--tanrei-comp-tabs-primary-pressed-covered-icon-color — #ffffff
--tanrei-comp-tabs-primary-pressed-covered-label-color — #ffffff
--tanrei-comp-tabs-primary-pressed-icon-color — #262626
--tanrei-comp-tabs-primary-pressed-label-color — #262626
--tanrei-comp-tabs-primary-pressed-selected-active-indicator-color — #1b1b1b
--tanrei-comp-tabs-primary-pressed-selected-container-color — #ffffff · 10%
--tanrei-comp-tabs-primary-pressed-selected-covered-icon-color — #ffffff
--tanrei-comp-tabs-primary-pressed-selected-covered-label-color — #ffffff
--tanrei-comp-tabs-primary-pressed-selected-icon-color — #262626
--tanrei-comp-tabs-primary-pressed-selected-label-color — #262626
--tanrei-comp-tabs-primary-pressed-selected-state-layer-color — #ffffff
--tanrei-comp-tabs-primary-pressed-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-pressed-state-layer-color — #262626
--tanrei-comp-tabs-primary-pressed-track-color — #eeeeee
--tanrei-comp-tabs-primary-pressed-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-pressed-underline-shape — 3px
--tanrei-comp-tabs-primary-pressed-underline-thickness — 3px
--tanrei-comp-tabs-primary-selected-active-indicator-color sys.color.primary #1b1b1b
--tanrei-comp-tabs-primary-selected-container-color — #000000 · 0%
--tanrei-comp-tabs-primary-selected-covered-icon-color sys.color.on-primary #ffffff
--tanrei-comp-tabs-primary-selected-covered-label-color sys.color.on-primary #ffffff
--tanrei-comp-tabs-primary-selected-icon-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-primary-selected-label-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-primary-selected-label-emphasis — 0
--tanrei-comp-tabs-primary-selected-state-layer-color comp.tabs.primary.selected.covered-label.color #ffffff
--tanrei-comp-tabs-primary-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-state-layer-color comp.tabs.primary.label.color #262626
--tanrei-comp-tabs-primary-track-color sys.color.secondary-container #eeeeee
--tanrei-comp-tabs-primary-underline-color — #000000 · 0%
--tanrei-comp-tabs-primary-underline-shape sys.shape.corner.extra-small 3px
--tanrei-comp-tabs-primary-underline-thickness — 3px
--tanrei-comp-tabs-secondary-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-secondary-active-indicator-shape sys.shape.corner.full 9999px
--tanrei-comp-tabs-secondary-active-indicator-thickness sys.border.thick 2px
--tanrei-comp-tabs-secondary-container-color — #000000 · 0%
--tanrei-comp-tabs-secondary-covered-icon-color sys.color.on-secondary #ffffff
--tanrei-comp-tabs-secondary-covered-label-color sys.color.on-secondary #ffffff
--tanrei-comp-tabs-secondary-disabled-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-secondary-disabled-active-indicator-shape — 9999px
--tanrei-comp-tabs-secondary-disabled-active-indicator-thickness — 2px
--tanrei-comp-tabs-secondary-disabled-container-color — #000000 · 0%
--tanrei-comp-tabs-secondary-disabled-covered-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-covered-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-selected-active-indicator-color — #1b1b1b · 12%
--tanrei-comp-tabs-secondary-disabled-selected-container-color — #000000 · 0%
--tanrei-comp-tabs-secondary-disabled-selected-covered-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-selected-covered-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-selected-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-selected-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-selected-state-layer-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-disabled-state-layer-color — #1b1b1b · 38%
--tanrei-comp-tabs-secondary-disabled-track-color — #eeeeee
--tanrei-comp-tabs-secondary-disabled-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-disabled-underline-shape — 0px
--tanrei-comp-tabs-secondary-disabled-underline-thickness — 2px
--tanrei-comp-tabs-secondary-focused-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-secondary-focused-active-indicator-shape — 9999px
--tanrei-comp-tabs-secondary-focused-active-indicator-thickness — 2px
--tanrei-comp-tabs-secondary-focused-container-color — #262626 · 10%
--tanrei-comp-tabs-secondary-focused-covered-icon-color — #ffffff
--tanrei-comp-tabs-secondary-focused-covered-label-color — #ffffff
--tanrei-comp-tabs-secondary-focused-icon-color — #262626
--tanrei-comp-tabs-secondary-focused-label-color — #262626
--tanrei-comp-tabs-secondary-focused-selected-active-indicator-color — #525252
--tanrei-comp-tabs-secondary-focused-selected-container-color — #ffffff · 10%
--tanrei-comp-tabs-secondary-focused-selected-covered-icon-color — #ffffff
--tanrei-comp-tabs-secondary-focused-selected-covered-label-color — #ffffff
--tanrei-comp-tabs-secondary-focused-selected-icon-color — #262626
--tanrei-comp-tabs-secondary-focused-selected-label-color — #262626
--tanrei-comp-tabs-secondary-focused-selected-state-layer-color — #ffffff
--tanrei-comp-tabs-secondary-focused-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-focused-state-layer-color — #262626
--tanrei-comp-tabs-secondary-focused-track-color — #eeeeee
--tanrei-comp-tabs-secondary-focused-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-focused-underline-shape — 0px
--tanrei-comp-tabs-secondary-focused-underline-thickness — 2px
--tanrei-comp-tabs-secondary-hovered-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-secondary-hovered-active-indicator-shape — 9999px
--tanrei-comp-tabs-secondary-hovered-active-indicator-thickness — 2px
--tanrei-comp-tabs-secondary-hovered-container-color — #262626 · 8%
--tanrei-comp-tabs-secondary-hovered-covered-icon-color — #ffffff
--tanrei-comp-tabs-secondary-hovered-covered-label-color — #ffffff
--tanrei-comp-tabs-secondary-hovered-icon-color — #262626
--tanrei-comp-tabs-secondary-hovered-label-color — #262626
--tanrei-comp-tabs-secondary-hovered-selected-active-indicator-color — #525252
--tanrei-comp-tabs-secondary-hovered-selected-container-color — #ffffff · 8%
--tanrei-comp-tabs-secondary-hovered-selected-covered-icon-color — #ffffff
--tanrei-comp-tabs-secondary-hovered-selected-covered-label-color — #ffffff
--tanrei-comp-tabs-secondary-hovered-selected-icon-color — #262626
--tanrei-comp-tabs-secondary-hovered-selected-label-color — #262626
--tanrei-comp-tabs-secondary-hovered-selected-state-layer-color — #ffffff
--tanrei-comp-tabs-secondary-hovered-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-hovered-state-layer-color — #262626
--tanrei-comp-tabs-secondary-hovered-track-color — #eeeeee
--tanrei-comp-tabs-secondary-hovered-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-hovered-underline-shape — 0px
--tanrei-comp-tabs-secondary-hovered-underline-thickness — 2px
--tanrei-comp-tabs-secondary-icon-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-secondary-label-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-secondary-pressed-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-secondary-pressed-active-indicator-shape — 9999px
--tanrei-comp-tabs-secondary-pressed-active-indicator-thickness — 2px
--tanrei-comp-tabs-secondary-pressed-container-color — #262626 · 10%
--tanrei-comp-tabs-secondary-pressed-covered-icon-color — #ffffff
--tanrei-comp-tabs-secondary-pressed-covered-label-color — #ffffff
--tanrei-comp-tabs-secondary-pressed-icon-color — #262626
--tanrei-comp-tabs-secondary-pressed-label-color — #262626
--tanrei-comp-tabs-secondary-pressed-selected-active-indicator-color — #525252
--tanrei-comp-tabs-secondary-pressed-selected-container-color — #ffffff · 10%
--tanrei-comp-tabs-secondary-pressed-selected-covered-icon-color — #ffffff
--tanrei-comp-tabs-secondary-pressed-selected-covered-label-color — #ffffff
--tanrei-comp-tabs-secondary-pressed-selected-icon-color — #262626
--tanrei-comp-tabs-secondary-pressed-selected-label-color — #262626
--tanrei-comp-tabs-secondary-pressed-selected-state-layer-color — #ffffff
--tanrei-comp-tabs-secondary-pressed-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-pressed-state-layer-color — #262626
--tanrei-comp-tabs-secondary-pressed-track-color — #eeeeee
--tanrei-comp-tabs-secondary-pressed-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-pressed-underline-shape — 0px
--tanrei-comp-tabs-secondary-pressed-underline-thickness — 2px
--tanrei-comp-tabs-secondary-selected-active-indicator-color sys.color.secondary #525252
--tanrei-comp-tabs-secondary-selected-container-color — #000000 · 0%
--tanrei-comp-tabs-secondary-selected-covered-icon-color sys.color.on-secondary #ffffff
--tanrei-comp-tabs-secondary-selected-covered-label-color sys.color.on-secondary #ffffff
--tanrei-comp-tabs-secondary-selected-icon-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-secondary-selected-label-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-secondary-selected-label-emphasis — 0
--tanrei-comp-tabs-secondary-selected-state-layer-color comp.tabs.secondary.selected.covered-label.color #ffffff
--tanrei-comp-tabs-secondary-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-state-layer-color comp.tabs.secondary.label.color #262626
--tanrei-comp-tabs-secondary-track-color sys.color.secondary-container #eeeeee
--tanrei-comp-tabs-secondary-underline-color — #000000 · 0%
--tanrei-comp-tabs-secondary-underline-shape sys.shape.corner.none 0px
--tanrei-comp-tabs-secondary-underline-thickness sys.border.thick 2px
--tanrei-comp-tabs-tonal-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-tonal-active-indicator-shape sys.shape.corner.full 9999px
--tanrei-comp-tabs-tonal-active-indicator-thickness sys.border.thick 2px
--tanrei-comp-tabs-tonal-container-color — #000000 · 0%
--tanrei-comp-tabs-tonal-covered-icon-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-tonal-covered-label-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-tonal-disabled-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-tonal-disabled-active-indicator-shape — 9999px
--tanrei-comp-tabs-tonal-disabled-active-indicator-thickness — 2px
--tanrei-comp-tabs-tonal-disabled-container-color — #000000 · 0%
--tanrei-comp-tabs-tonal-disabled-covered-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-covered-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-selected-active-indicator-color — #1b1b1b · 12%
--tanrei-comp-tabs-tonal-disabled-selected-container-color — #000000 · 0%
--tanrei-comp-tabs-tonal-disabled-selected-covered-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-selected-covered-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-selected-icon-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-selected-label-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-selected-state-layer-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-disabled-state-layer-color — #1b1b1b · 38%
--tanrei-comp-tabs-tonal-disabled-track-color — #eeeeee
--tanrei-comp-tabs-tonal-disabled-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-disabled-underline-shape — 0px
--tanrei-comp-tabs-tonal-disabled-underline-thickness — 2px
--tanrei-comp-tabs-tonal-focused-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-tonal-focused-active-indicator-shape — 9999px
--tanrei-comp-tabs-tonal-focused-active-indicator-thickness — 2px
--tanrei-comp-tabs-tonal-focused-container-color — #4b4b4b · 10%
--tanrei-comp-tabs-tonal-focused-covered-icon-color — #262626
--tanrei-comp-tabs-tonal-focused-covered-label-color — #262626
--tanrei-comp-tabs-tonal-focused-icon-color — #4b4b4b
--tanrei-comp-tabs-tonal-focused-label-color — #4b4b4b
--tanrei-comp-tabs-tonal-focused-selected-active-indicator-color — #262626 · 16%
--tanrei-comp-tabs-tonal-focused-selected-container-color — #262626 · 10%
--tanrei-comp-tabs-tonal-focused-selected-covered-icon-color — #262626
--tanrei-comp-tabs-tonal-focused-selected-covered-label-color — #262626
--tanrei-comp-tabs-tonal-focused-selected-icon-color — #4b4b4b
--tanrei-comp-tabs-tonal-focused-selected-label-color — #4b4b4b
--tanrei-comp-tabs-tonal-focused-selected-state-layer-color — #262626
--tanrei-comp-tabs-tonal-focused-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-focused-state-layer-color — #4b4b4b
--tanrei-comp-tabs-tonal-focused-track-color — #eeeeee
--tanrei-comp-tabs-tonal-focused-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-focused-underline-shape — 0px
--tanrei-comp-tabs-tonal-focused-underline-thickness — 2px
--tanrei-comp-tabs-tonal-hovered-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-tonal-hovered-active-indicator-shape — 9999px
--tanrei-comp-tabs-tonal-hovered-active-indicator-thickness — 2px
--tanrei-comp-tabs-tonal-hovered-container-color — #4b4b4b · 8%
--tanrei-comp-tabs-tonal-hovered-covered-icon-color — #262626
--tanrei-comp-tabs-tonal-hovered-covered-label-color — #262626
--tanrei-comp-tabs-tonal-hovered-icon-color — #4b4b4b
--tanrei-comp-tabs-tonal-hovered-label-color — #4b4b4b
--tanrei-comp-tabs-tonal-hovered-selected-active-indicator-color — #262626 · 16%
--tanrei-comp-tabs-tonal-hovered-selected-container-color — #262626 · 8%
--tanrei-comp-tabs-tonal-hovered-selected-covered-icon-color — #262626
--tanrei-comp-tabs-tonal-hovered-selected-covered-label-color — #262626
--tanrei-comp-tabs-tonal-hovered-selected-icon-color — #4b4b4b
--tanrei-comp-tabs-tonal-hovered-selected-label-color — #4b4b4b
--tanrei-comp-tabs-tonal-hovered-selected-state-layer-color — #262626
--tanrei-comp-tabs-tonal-hovered-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-hovered-state-layer-color — #4b4b4b
--tanrei-comp-tabs-tonal-hovered-track-color — #eeeeee
--tanrei-comp-tabs-tonal-hovered-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-hovered-underline-shape — 0px
--tanrei-comp-tabs-tonal-hovered-underline-thickness — 2px
--tanrei-comp-tabs-tonal-icon-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-tabs-tonal-label-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-tabs-tonal-pressed-active-indicator-color — #000000 · 0%
--tanrei-comp-tabs-tonal-pressed-active-indicator-shape — 9999px
--tanrei-comp-tabs-tonal-pressed-active-indicator-thickness — 2px
--tanrei-comp-tabs-tonal-pressed-container-color — #4b4b4b · 10%
--tanrei-comp-tabs-tonal-pressed-covered-icon-color — #262626
--tanrei-comp-tabs-tonal-pressed-covered-label-color — #262626
--tanrei-comp-tabs-tonal-pressed-icon-color — #4b4b4b
--tanrei-comp-tabs-tonal-pressed-label-color — #4b4b4b
--tanrei-comp-tabs-tonal-pressed-selected-active-indicator-color — #262626 · 16%
--tanrei-comp-tabs-tonal-pressed-selected-container-color — #262626 · 10%
--tanrei-comp-tabs-tonal-pressed-selected-covered-icon-color — #262626
--tanrei-comp-tabs-tonal-pressed-selected-covered-label-color — #262626
--tanrei-comp-tabs-tonal-pressed-selected-icon-color — #4b4b4b
--tanrei-comp-tabs-tonal-pressed-selected-label-color — #4b4b4b
--tanrei-comp-tabs-tonal-pressed-selected-state-layer-color — #262626
--tanrei-comp-tabs-tonal-pressed-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-pressed-state-layer-color — #4b4b4b
--tanrei-comp-tabs-tonal-pressed-track-color — #eeeeee
--tanrei-comp-tabs-tonal-pressed-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-pressed-underline-shape — 0px
--tanrei-comp-tabs-tonal-pressed-underline-thickness — 2px
--tanrei-comp-tabs-tonal-selected-active-indicator-color sys.color.secondary-container #262626 · 16%
--tanrei-comp-tabs-tonal-selected-container-color — #000000 · 0%
--tanrei-comp-tabs-tonal-selected-covered-icon-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-tonal-selected-covered-label-color sys.color.on-secondary-container #262626
--tanrei-comp-tabs-tonal-selected-icon-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-tabs-tonal-selected-label-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-tabs-tonal-selected-label-emphasis sys.typescale.emphasis.label 100
--tanrei-comp-tabs-tonal-selected-state-layer-color comp.tabs.tonal.selected.covered-label.color #262626
--tanrei-comp-tabs-tonal-selected-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-state-layer-color comp.tabs.tonal.label.color #4b4b4b
--tanrei-comp-tabs-tonal-track-color sys.color.secondary-container #eeeeee
--tanrei-comp-tabs-tonal-underline-color — #000000 · 0%
--tanrei-comp-tabs-tonal-underline-shape sys.shape.corner.none 0px
--tanrei-comp-tabs-tonal-underline-thickness sys.border.thick 2px
--tanrei-comp-tabs-track-padding sys.space.space50 4px
--tanrei-comp-tabs-track-shape sys.shape.corner.full 9999px
--tanrei-comp-tabs-track-visible — 1
--tanrei-comp-tabs-label-typescale sys.typescale.label 500 14px/1.4286

Fixtures

Canonical renderings. Every platform snapshots each fixture under the same id so the results can be compared.

Generated from specs/tabs.spec.json, tokens/comp/tabs.tokens.json, design/tokens/

Guidelines

When to use

Use tabs to split one subject into views and show one at a time: a project’s Overview, Activity and Settings; a profile’s Posts and Replies; a file’s Preview and History. The views are peers, and people move between them in any order.

Use a navigation bar or drawer to move between the destinations of an app, which are different subjects. Don’t use tabs for steps that must be completed in order, or to filter one list: use chips for filters. Tabs change the view, never trigger an action.

In the Tanrei system, which does not ship the segmented button, tabs also take its place when one choice among a few changes what is shown below: Day, Week and Month over a calendar, or Standard, Medium and High over the examples of a page. Such a row needs no panel. Give it a name that says what is chosen. Where it should stay quiet beside what it changes, use the tonal variant, without its track, and the small size in a header. A choice that sets a value sent with a form, or that changes nothing a person can see, is not tabs. Use radio buttons for it.

Tabs, segmented button or button group

Component What it is Use for
Tabs Views of one subject, or one choice that changes what is shown below. One tab is selected, and the arrow keys move and select. Switching what is shown below: Overview, Activity, Settings; Day, Week, Month.
Segmented button Two to five joined options that set a value, one or several of them. The default Tanrei system does not ship it; the Material and cupertino presets do. A setting with a few fixed answers, in a product that uses one of those presets.
Button group Separate buttons laid out together. Each is pressed on its own, and the group holds no selection. Related actions: Cancel and Save; Bold, Italic and Underline.

A quick test: if choosing changes what is shown below, it is tabs, with or without a panel. If it sets a value that is sent with a form, it is radio buttons, or a segmented button where the system ships one. If pressing does something, it is a button group.

Choosing a variant, a size and a layout

Choice What it does Use for
Primary An icon stacks above the label at the medium size and sits before it at the small size, as the design system sets for each size (comp.tabs.icon.placement). The indicator is drawn in the primary variant’s colors. The main views of a screen, at the top of its content.
Secondary An icon sits before the label, on one line. The indicator is drawn in the secondary variant’s colors. Views within one of those views, below a row of primary tabs or inside a panel.
Tonal The indicator is a soft layer, and the selected label is darker and heavier than the others. An icon sits before the label. A choice that should not draw the eye from what it changes: the languages of a code sample, a contrast, a unit.
Medium (size, the default) The row is 48 high and the indicator 40. A row at the top of a screen’s content.
Small (size) The row is 32 high and the indicator 24. The padding beside a label and the icon are smaller, and an icon sits before the label in every variant. The label’s text is the same size. A row in a header or a toolbar, beside small buttons.
Hug (fullWidth false, the default) Sizes each tab to its content and keeps every label whole on one line, however long or large. The track ends with its last tab, and the tabs scroll sideways inside it when they do not fit. The indicator follows the selected tab’s content. Most rows: more than a few tabs, labels of different lengths, labels that are translated or written by users.
Fill (fullWidth true) Divides the row’s width equally. A label that does not fit its share is truncated at its end with an ellipsis. The indicator is as wide as the tab’s share. Two to five short labels that must span the row.

fullWidth is spelled full-width on the web, fullWidth on SwiftUI, Compose and React Native, and on Flutter as its inverse, isScrollable, which defaults to true, as in Flutter’s own TabBar.

A label never wraps onto a second line or breaks within a word, in either layout, and an icon never shrinks to make room. In a row that fills, a truncated label stays the tab’s whole accessible name, and the platform shows it whole where it can: a plain tooltip on hover and keyboard focus on the web, a tooltip on long press and hover on Compose, a Tooltip on Flutter, the large-content viewer on SwiftUI (and .help on macOS). React Native has no tooltip of its own, so there it is not shown whole.

The three variants are one control. They differ in how much the selection weighs and in nothing else: the keys, the roles and what assistive technology is told are the same.

A design system may add variants and sizes. A variant it adds is laid out as primary.

Sizes

A small row is as high as a small Button, so the two share a line in a header. It is lower than the minimum target, and it is not made taller for it. A press just above or just below the row, in line with a tab, still selects that tab. A control that stands there keeps its own press: a press on a button under the row goes to the button. With larger text a small row grows as a medium one does, and nothing is cut.

The track and the indicator

Tabs and the switch share one model: a thumb on a track, which slides to what is chosen and can be slid there by hand. A switch’s two states are not equals, so its whole look changes between them. Tabs are equal choices, so their track never changes and only the indicator moves.

  • The track. One quiet fill behind the whole row, with fully rounded ends and no outline. It is as tall as the row. In a row that hugs it ends a small inset after its last tab; in a row that fills it is as wide as the row. It is the same whichever tab is selected and whatever state a tab is in. When the tabs do not fit, the track stays still and the tabs scroll inside it, clipped to its rounded ends.
  • The indicator. The selected tab has the indicator behind its content. It is as tall as the row less the track’s inset above and below, so it has the same height on every tab: 40 in a row of 48, and 56 where primary tabs stack an icon above a label, in the default system. In a row that hugs it is as wide as the tab’s content plus padding, and a circle where that content is not wider than it is tall: an icon alone, a one-letter label, one full-width character. In a row that fills it is as wide as the tab’s share. The rule is the same for primary and secondary tabs.
  • The same inset on three sides. The indicator keeps one inset from the track’s top, from its bottom and, on the first and the last tab, from its end, at every row height. Its rounded end and the track’s are concentric. A tab whose indicator is a circle is as wide as that circle, or as the minimum target where that is larger.
  • Hover, focus and press are drawn in the box the indicator has on that tab, or would have if the tab were selected, never on the tab’s whole rectangle. On the selected tab they are drawn over the indicator, in the color of the content it covers, so the selected tab answers hover and press as the others do. The whole tab still answers the pointer.
  • A row without its track. Set track to false and the fill behind the row is not drawn. Nothing else changes: the row keeps its size, the inset, the place of every tab and every color, so a row with its track and one without line up. Use it where the row stands on something that is already a bar: a header, a card, the heading of a code sample. Left unset, track follows the design system (comp.tabs.track.visible, on in the default system). On the web the attribute is track="false".
  • The tonal indicator. It is a translucent layer of the selected label’s color, laid over the track or over whatever the row stands on, so it is seen by the same step on every surface. It is a quiet mark by intent, so the selected label is a second one: darker, and one weight step heavier, so the selection is never told by color alone. Every tab keeps room for its label at the heavier weight, so nothing in the row moves when the selection does.
  • Covered labels. A label and an icon are drawn in their covered colors exactly where the indicator covers them. While the indicator slides or is dragged, each label it passes over changes only where it is covered, and the new tab’s label is wholly covered the moment the indicator lands. The covered copy is drawn with the system’s colors, never with a blend, so its contrast is the one the tokens promise.

A design system can also draw a line on the row’s bottom edge under the selected tab, and a divider along the whole row. Both are there in every system and are transparent in the default one. The Material presets color them and make the track and the indicator transparent, as Material’s tabs do.

Dragging across the row

  • A tap still selects. A drag across the row, with touch, a pen or a mouse, carries the indicator with the pointer instead. Its place, its width and the covered colors of the labels under it change continuously, with no threshold.
  • The release chooses. The tab the indicator is nearer to is chosen, or the one a fling is toward. If that is another tab, the row selects it and emits change once, never for the tabs the indicator passed. If it is the selected tab, the indicator returns and nothing is emitted.
  • Nothing else changes on the way. The selection, the panel, keyboard focus and what assistive technology is told stay as they were until the release. Escape, or the platform cancelling the pointer, returns the indicator to the selected tab.
  • Scrolling keeps working. A drag that goes up or down first scrolls the page. On the web, in a row whose tabs scroll, only a drag that starts on the selected tab moves the indicator; any other sideways drag scrolls the tabs.
  • Reduced motion. The indicator still follows the pointer, which the person’s own finger moves, and lands on its tab at once.

The transition

transition says how a row draws a new selection.

Value What it does
slide The indicator slides and resizes to the new tab, and the labels change color under it. A tab out of view is scrolled in with a motion.
none The new selection is drawn whole in one frame. Nothing travels and nothing fades.
auto (the default) Takes the design system’s answer (comp.tabs.motion.transition, slide in the default system).

Use none for a row beside content it has just changed, where a sliding indicator would be the only thing still moving. Under none a drag across the row still carries the indicator, and lands on its tab at once on release. Hover, focus and press are answered the same under every value. Where motion is reduced, a selection is drawn as none draws it, whatever transition says.

A Tab view has a transition of its own for its panels. Set to none, it also stops the slide of the row it draws.

Selection and panels

  • By value. Every tab has a value, unique in its row. A value survives tabs being added, removed or reordered, where an index would quietly select another view. A value that names no tab selects none and shows no panel, and debug builds report it.
  • Automatic activation. Moving focus with the arrow keys also selects the tab, and the panel changes at once. If a panel takes time to load, show a loading state inside it rather than delaying the switch.
  • Disabled tabs are skipped. The arrow keys pass over them and they are never the Tab stop, because landing on one would select a tab that cannot be selected. This differs from list and menu, whose disabled items stay reachable because moving there selects nothing.
  • The indicator slides. When the selection changes, the indicator slides and resizes from the old tab to the new one, a circle widening into a capsule on the way. It moves at once where motion is reduced. When the row first appears, or the selected tab changes size without a new selection (a font arriving, the text size changing), the indicator is put in place without sliding. A tab that becomes selected or focused while partly scrolled out of view is scrolled into view.
  • The row draws its own track. Place it on the surface of the screen or of an app bar. It needs no container of its own.
  • In a Tab view. A Tab view that draws its own row uses these tabs, with the same tokens and behaviour. While a person swipes a Tab view, it moves the indicator through indicatorOffset: 0 on the selected tab, 1 on the next enabled tab and −1 on the previous one, and in between as the finger moves. The indicator follows it frame by frame, without a slide of its own. Leave it at 0 unless you drive the indicator yourself. A swipe of the view and a drag across its row are one gesture in two places: a swipe moves one panel, and the panels travel with it; a drag across the row can pass several tabs, and the panels wait for the release.

Sharing a choice

When the same question is answered in many places — the platform of every code sample on this site, the units of every figure in a report — give those rows the same group. Selecting a tab in one selects the tab of the same value in every other row that has one; a row that has no such tab keeps its own.

  • Matched by value. A group follows a tab’s value, never its label, so a translated or reworded label keeps the choice.
  • One row moves, one row announces. The row a person used slides and emits change, once. Every other row takes the choice at once, without a motion, without taking focus and without scrolling, and emits nothing. The row the person used stays where it is in the window while the rows above it change height.
  • Remembering is separate. rememberAs names the key under which a person’s choice is kept on their device and restored before the row is first drawn. Without a key nothing is stored. The key is yours, used exactly as given, and only the tab’s value is stored; whether a remembered preference needs consent where your product is used is your judgement.
  • On the web the tabs keep the group. group and remember-as are attributes, and the key is a key of localStorage. On SwiftUI, Compose, Flutter and React Native, rows given the same selection state already share it, and your product keeps and stores that state.

Two rows of one group: choose a tab in either

A marathon is 42.195 km.

A marathon is 26.2 miles.

A brisk walk is 6 km/h.

A brisk walk is 3.7 mph.

A brisk walk is 3.2 knots.

In the demo, Nautical exists in the second row only: choosing it leaves the first row on its own choice, and the first row follows again as soon as the choice is one it has. Neither row names a key, so nothing is stored.

The tabs of this site are an example: every set of platform samples is a Tab view of one group.

Vertical tabs

Set orientation to vertical to stack the tabs in a column at the start edge, beside the views they switch: the categories of a settings page or of a dialog, the sections of one record. It is the same control in everything else: one selected tab, an indicator that slides and can be dragged, the panels, a shared choice.

Vertical tabs beside their panels

Language, region and start page.

Email, push and a weekly summary.

Who can see your profile.

  • The column. Every tab is as wide as the widest, with its icon before its label at the start edge, in every variant and at every size. The tabs touch above and below, so each is at least the minimum target tall: a small column’s tabs are 44 tall (48 with touch input), not 32. Make the column wider with ::part(tablist). In a column narrower than its widest tab, a label is truncated and shown whole in a tooltip.
  • The indicator and the track. The indicator is the tab less the track’s inset on every side. The track’s corners are the indicator’s plus that inset, so the two stay concentric at the ends however tall the column is. A design system that draws a line under the selected tab draws it on the column’s end edge, beside the panel, with the divider down that edge.
  • Keys. Down and Up Arrow move to the next and previous tab and select it, and Home and End go to the ends. Right and Left Arrow do nothing, so they stay the page’s. Assistive technology is told the tab list is vertical.
  • Scrolling. Give the row a height, and tabs that do not fit scroll up and down inside the track. A touch scrolls them; only a mouse or a pen drags the indicator. fullWidth does not apply to a column.

Icons

  • Give every tab of a row an icon, or none. Icons are drawn outlined when not selected and filled when selected.
  • labelHidden draws the icon alone. The label is still required: it stays the tab’s accessible name. A tab with labelHidden and no icon draws its label anyway, and debug builds report it.
  • A tab with an empty label and no icon has no content: its indicator is an empty circle, and the tab has no accessible name. Debug builds report the empty label; give the tab a label.
  • To show a count on a tab, place a Tanrei badge on its icon yourself. Tabs have no badge prop.
  • Keep labels short: one or two words that name the view, such as “Overview” or “Billing”. In a row that fills, a long label is cut off.
  • Keep the default hug when labels are long, translated, written by users, or read at large text sizes.
  • Keep the order of tabs stable, so people can find a view where they left it.
  • Put secondary tabs inside a view of primary tabs, not the other way round.
  • Keep a tab that is sometimes unavailable in place and disabled, so the row does not change shape.
  • Use vertical tabs for the views of one subject when there are many of them or their labels are long: the categories of a settings page, the sections of a record.

Don’t

  • Don’t use tabs for app navigation. That is the navigation bar’s or drawer’s job.
  • Don’t make a tab open a dialog, start an action or leave the screen. A tab has no destination of its own: to move between pages, use links, a navigation bar or a navigation drawer.
  • Don’t nest two rows of the same variant.
  • Don’t mix tabs with and without icons in one row.
  • Don’t use vertical tabs to choose where to go in an app. A column of destinations is the navigation drawer’s job: its items are links, and choosing one changes the address.

Accessibility

Role
tablist (radiogroup while the row has no panel)
Labeling
Each tab is named by its label, drawn or not (labelHidden), and whole even when it is drawn truncated. The row is named by its own label, which in a row without panels says what is chosen. The selected tab's panel is named by its tab; a row with no panel names nothing but itself and its tabs. Icons, the covered copies of labels and icons, the track, the indicator, the underline and the divider are decorative.
Minimum target
The hit area meets the minimum target size even when the visual size is smaller. minimum 32px · pointer 44px · spacing 8px · touch 48px

Keyboard

Keys Action
Tab / Shift+Tab Moves focus to the selected tab, and from it into its panel or, where the row has no panel, to whatever follows the row.
Right Arrow / Left Arrow Horizontal, or any row without panels: moves focus to the next or previous enabled tab, wrapping, and selects it (mirrored in right-to-left layouts). Vertical with panels: does nothing.
Down Arrow / Up Arrow Vertical, or any row without panels: moves focus to the next or previous enabled tab, wrapping, and selects it. Horizontal with panels: does nothing (the page scrolls).
Home / End Moves focus to the first or last enabled tab and selects it.
Enter / Space Selects the focused tab (needed only after the product has moved focus without selecting).
Escape (during a drag) Cancels a drag that a pointer has in progress; the indicator returns to the selected tab and nothing is selected.

Notes

  • Activation is automatic: moving focus with the arrow keys selects the tab, as the WAI-ARIA tabs pattern recommends when a panel shows without a noticeable delay. A product whose panels load slowly shows a loading state in the panel; a manual mode (focus first, Enter to select) can be added later as a new prop without breaking this spec.
  • Tabs and the switch share one model: a thumb on a track that slides to what is chosen and follows a finger. A switch has two states, and its track, outline and handle differ between them; tabs have two or more equal choices, so their track never changes and the active indicator alone moves.
  • The drag is never the only way (WCAG 2.5.7): every tab is selected by a single tap or click on it, and by the arrow keys. With a screen reader running, a one-finger drag belongs to the reader; tabs are then selected by the reader's activation, as before.
  • A drag changes what is drawn, continuously (the indicator's place and width, and the labels' colors under it), and nothing that assistive technology is told: no tab is exposed as selected on the way, no panel changes, and no change is emitted for the tabs the indicator passes (TAB-30). There is no threshold in the look; the place where two tabs meet decides only what a release chooses.
  • Selection is the row's: value holds the selected tab's value, and change carries the new one. A tab's disabled is its own.
  • Web: `tanrei-tab-panel` elements are matched to tabs by their `value` attribute, whether they are the row's children or a linked `tanrei-tab-view`'s, and get role tabpanel, aria-labelledby and the tab's aria-controls; a row with none is a radio group of radios (TAB-42). SwiftUI, Compose, Flutter and React Native have no tab panel role; there the selected panel follows the row in reading order, and each README says so.
  • Tabs switch views of one subject. Moving between destinations of an app belongs to the navigation bar or drawer.
  • A Tab view (`tab-view`) draws its own row of tabs from its panels by default, or is linked to a row placed elsewhere; either way it holds the panels. The link is by value: the Tab view shows the panel of `value`, follows the tabs when a tab is selected, and sets `value` on both and emits its own change when the user swipes it (TBV-2, TBV-4). While a swipe is in progress it sets `indicatorOffset`, which places the active indicator and the underline between two tabs (TAB-26); TAB-24 makes the covered colors follow the indicator wherever it is drawn. A drag across the row and a swipe of the view are one gesture in two places: the indicator follows the finger, the selection is made on release (from halfway, or by a fling), a cancelled gesture returns it, and nothing is announced before the release. They differ in reach: a swipe moves one panel, and the panels travel with it; a drag across the row can pass several tabs, and the panels wait for the release, then switch as for a selected tab (TBV-2). A row without a Tab view shows its own panels (TAB-13).
  • Text size never hides a label: a tab that hugs its content grows wider and the row scrolls; a tab that shares the row's width truncates its label, which stays the tab's accessible name and is shown whole where the platform can (TAB-25). A product whose labels are long, translated, written by users, or read at large text sizes keeps the default hug.
  • How a state looks is a value decision of the active design system; components read the state's tokens and never compute the effect themselves.
  • A shared choice (group) is one decision shown in many places: a person who picks a platform, a language or a unit once should not be asked again by every sample on the page. The rows that follow change silently and at once (TAB-38): nothing is announced, focus stays on the tab the person used, and each row tells assistive technology its selected tab when the person reaches it. A change of content elsewhere on the page is not a change of context (WCAG 3.2.2), but it should not surprise: rows of one group carry the same labels for the same values (WCAG 3.2.4), and a product whose rows are far apart says near the first one that the choice applies to the whole page.
  • Tabs of different rows are matched by value, never by label, so a choice survives a translated label, an icon-only tab and a change of wording, and a remembered choice still holds after the person changes language.
  • Remembering (rememberAs) is off unless the product names a key, and what is kept is one short string the product itself wrote as a tab's value: no text a person typed, no identifier, nothing sent anywhere by the component. Whether keeping a preference on the device needs the person's consent where the product is used is the product's to decide, which is why there is no default key and no remembering by default. A person who blocks storage loses nothing but the memory (TAB-39).
  • The page does not move under the person (TAB-40): a row whose choice changes the height of samples above it stays under the pointer and under keyboard focus, which is what WCAG 2.4.3 and 3.2.2 ask of a control that changes content elsewhere, and what keeps a person using magnification from losing their place.
  • A row without panels (TAB-42) is still drawn as tabs when one choice changes what is shown below it: Day, Week and Month over a calendar; Standard, Medium and High over the examples of a page; a unit over a table; a filter over a list. It is announced as what it is, one choice among a few: a radio group named by the row's label, whose tabs are radio buttons, the selected one checked. A tab list promises panels: in the WAI-ARIA tabs pattern every tab controls the panel it shows, and a screen reader that says "tab" tells a person that a panel follows, so a tab list with no panel is announced wrongly. The radio group keeps what a person does the same: one Tab stop, and arrows that move and select, as a radio group's arrows check what they reach. The row knows which it is from what it holds — its own panels, a Tab view's, or the panel of a component that draws it — and not from a prop, so what it says can never disagree with what follows it: a row is a tab list from the moment it has a panel. The row's label tells a person what they are choosing, and the product draws the result after the row in reading order where it can, so that a person who has chosen finds it by reading on; where the choice reaches the whole page, the label or the words beside the row say so. A choice that sets a value sent with a form is radio buttons, which the form submits.
  • Variant, track, size and transition say how the row looks and moves, never what it is: every combination is exposed alike, as a tab list where the row has a panel and as a radio group where it has none (TAB-42), with one keyboard model (TAB-49, TAB-50). Primary is the row of a screen's main views, secondary a row inside one of them, tonal the quiet row — in the heading of a sample, in a header, in a toolbar — where the choice should not draw the eye from what it changes.
  • The selected tab is told to assistive technology as a state, and to sight by the indicator and by its label. A tonal row's indicator is a quiet mark on purpose: in the default system a layer about 1.4:1 from what it lies on, the same step on the track and on any surface, with the selected label darker than the others. It is not held to 3:1 against its ground, as the selected destination of a navigation drawer is not. A person who needs more has the system's medium and high contrast and the platform's forced colors, where the indicator is a filled highlight (TAB-45); a product that cannot accept the quiet mark uses secondary or primary, which are filled.
  • Without its track (TAB-49) a row has no ground of its own: its labels are held on every surface role of the system, and a product that places a row on a color of its own checks the labels against it, as it does for text.
  • A small row (TAB-44) is 32 tall and still pressed over the minimum target (TAB-5, WCAG 2.5.8): its hit area reaches above and below it, which a header or a toolbar has room for. Directly over content that answers a press at its top edge, that content wins the overlap; a product that stacks a small row tight against such content uses the medium size.
  • transition none (TAB-43) takes away the motion the row plays by itself when the selection changes, in every motion setting; it takes away no way of selecting, and a drag across the row still carries the indicator under the finger. Where motion is reduced every row is drawn that way, and the state colors then change at once too (TAB-23), a ripple appearing whole (TAB-9).
  • A vertical tab list (orientation vertical) is still tabs: one subject's views, the categories of a settings page or the sections of one record, with the arrow keys of its axis (Down and Up); a column without panels is a radio group, whose arrows move in both axes (TAB-42). It is not a menu of destinations — moving between the pages of an app belongs to the navigation drawer, whose items are links. A column never wraps a label: a column narrower than its widest tab truncates the label, which stays the tab's whole name, and shows it whole in a tooltip (TAB-61).

Generated from specs/tabs.spec.json, tokens/sys/measure.tokens.json

What the component handles

  • A row with panels is exposed as a tab list named by its label. Each tab has the tab role, its selected state, its position and the number of tabs, and is named by its label, whether or not the label is drawn.
  • The selected tab’s panel is exposed as a tab panel, named by its tab and associated with it where the platform can express that. The other panels are hidden from view and from assistive technology. A panel with nothing focusable in it is made focusable itself, so that Tab reaches it.
  • The row is one Tab stop: the selected tab. Right and Left Arrow move to the next and previous enabled tab, wrapping at the ends, and select it. They are swapped in right-to-left layouts. Home and End go to the first and last enabled tab. Enter and Space select the focused tab. Tab moves from the selected tab into its panel.
  • Disabled tabs are skipped by the arrow keys, are never the Tab stop, and are announced as disabled.
  • The focus ring surrounds the box the indicator has on the focused tab, in its shape, and is held inside the track, so a row that scrolls cannot clip it. It shows for keyboard focus only.
  • Tabs touch their neighbours, so a tab narrower than the minimum target grows to it in width. A row lower than the target (the small size) is not made taller: its tabs answer a press a little above and below the row, and yield to a control that stands there.
  • A row without panels is one choice among a few, and is exposed as one: a radio group named by its label, each tab a radio button, the selected one checked. A tab list would tell a screen reader user that a panel follows. Every arrow key moves to the next or previous enabled tab and selects it, in either orientation, as in a radio group. What the choice changes below the row is yours to draw. Tab moves from the selected tab to whatever follows the row. The row becomes a tab list as soon as it has a panel.
  • With forced colors the indicator is a filled highlight in every variant, with or without the track, so a tonal row shows its selection as plainly as a primary one.
  • Larger text never hides a label from assistive technology. A tab that hugs its content grows wider and the row scrolls. A tab that shares the row truncates its label, which stays its accessible name and is shown whole where the platform can.
  • Icons, the track, the indicator, the underline and the divider are hidden from assistive technology. The covered copy of each label under the indicator is decorative, so each label is announced once.
  • A drag across the row is never the only way: every tab is selected by a tap or a click on it, and by the arrow keys. During a drag no tab is exposed as selected, no panel changes and nothing is announced; the release makes the selection, and Escape cancels the drag. With a screen reader running, a one-finger drag belongs to the reader, and tabs are selected by its activation.
  • In a group, the rows that follow change silently. Nothing is announced, focus stays on the tab the person used, and that row stays where it is in the window, so a person using magnification does not lose their place. Each row tells assistive technology its selected tab when the person reaches it.
  • Debug builds report a tab without a label, a value that is empty or repeated, a value that names no tab, a hidden label without an icon, a variant or a size the design system does not have, and a transition the tabs do not define.

Known platform gaps:

  • SwiftUI, Compose and React Native have no tab panel role. The selected panel follows the row in reading order.
  • SwiftUI, Compose, Flutter and React Native still expose a row without panels as tabs, not as a radio group.
  • SwiftUI has no tab list or tab role either. The row is a container named by label with the tab bar trait, and each tab is a button with the selected trait. On iOS, VoiceOver states a tab’s position from the tab bar trait; elsewhere the position is the tab’s value (“2 of 3”), in English only.
  • SwiftUI cannot associate the panel with its tab, and cannot tell whether a panel holds anything focusable, so a panel with nothing focusable is not made a Tab stop.
  • Compose has no tab list role. The row is a selectable group named by contentDescription and carries the number of tabs as collection info. Each tab has the tab role, its selected state and its position.
  • Compose and React Native leave the panel to you. They do not hide the other panels or make an empty panel focusable.
  • Compose: a badge placed on a tab’s icon is not announced, because the icon slot is decorative. A badge wrapping the whole tab is announced, but is drawn at the tab’s corner.
  • Compose: the arrow keys and scrolling through the real input pipeline, and TalkBack reading each tab’s role and position, have been tested on Robolectric only, not yet on a device.
  • Flutter’s semantics have no position, no number of tabs and no relation from a tab to its panel. TanreiTabBarView is a tab panel named by the semanticLabels you pass.
  • React Native has no tab panel role and no relation from a tab to its panel. A tab carries its position as its value text (“2 of 3”), in English unless you pass positionText.
  • React Native: the arrow keys, scrolling a tab into view (and its right-to-left offsets) and the sliding indicator have not yet been checked on a device.
  • Web: a tab reached with the Tab key while scrolled out of view is scrolled into view by the browser at once, not over the indicator’s motion.
  • Flutter: a scrollable row whose selected tab starts out of view shows one frame at its start edge before it jumps to the tab.

What you need to do

  • Give every tab a label, including an icon-only tab.
  • Name the row with label when a screen has more than one row of tabs. Say what the views are views of, such as “Project”.
  • Keep each tab’s value unique in its row, and stable while the screen is shown.
  • On Compose and React Native, render only the selected panel, right after the row. When it has nothing focusable in it, make it focusable and name it after its tab, so that Tab reaches it.
  • On Flutter, pass semanticLabels to TanreiTabBarView with each tab’s label.
  • On React Native, pass positionText in your product’s language.
  • Show a loading state inside a panel that takes time to load. The arrow keys select as they move.
  • Give the rows of one group the same labels for the same values. Where the rows are far apart, say near the first one that the choice applies to the whole page.
  • Choose the rememberAs key yourself, and name it in your privacy notice where your product needs one. Nothing is stored without a key.