Tab view
Shows the view of the selected tab under its own row of Tabs, or linked to a row placed elsewhere, and lets a person swipe from view to view with the tabs' indicator following the finger.
- Category
- Navigation
- Tokens
comp.tab-view- Level
- Composed
- Built from
- Tabs
- Web
tanrei-tab-viewschedule - SwiftUI
TanreiTabViewradio_button_unchecked - Jetpack Compose
TanreiTabViewradio_button_unchecked - Flutter
TanreiTabViewradio_button_unchecked - React Native
TabViewradio_button_unchecked
Generated from docs/status/status.json, specs/tab-view.spec.json
A tab view holds one panel per tab and shows the panel of the selected tab. Selecting a tab switches the view with a short shift, fade and blur; a person can also swipe the view sideways, and the tabs’ indicator follows the finger, settling on the tab the view lands on. Under reduced motion the switch is a crossfade that still follows the finger, with no shift and no blur.
A tab view with its own row: choose a tab, or swipe the panel with touch or a pen
Three open tasks, due this week.
Ada commented on the plan an hour ago.
Visible to everyone in the workspace.
A tab view has two forms:
- Its own row, the default. The view draws a row of Tabs across its top, with the panels directly below it. Each panel carries its tab’s
label,icon,labelHiddenanddisabled, so one declaration makes both the tab and its view. The view’svariant,size,track,fullWidth,label,groupandrememberAsare the row’s. The row is a real Tabs: the same track and indicator, the same tokens, variants and behaviour, so a layer that restyles the tabs restyles it too. It never moves, fades or blurs with a switch. - Linked, for tabs placed elsewhere.
tabslinks the view to a row in an app bar or a sticky header. While it is set, the view draws no row of its own, even while that row is missing, and its row props and the panels’ tab props are unused.
With its own row, the view emits change for every selection, from a tab or a swipe, and the row’s own change does not reach you. Linked, the tabs emit change for a tab and the view for a swipe, so every selection is announced once.
Tab views that name the same group show the same choice, and rememberAs keeps it on the person’s device. The content tabs of this site are an example: every set of platform samples is a secondary tab view of one group, remembered under the site’s own key.
On the web it is <tanrei-tab-view> holding <tanrei-tab-panel> children, the same element a row of Tabs uses for its own panels. The selected panel is the view’s value, kept equal to its tabs’ value: setting either sets the other, without an event. When the view is linked, tabs names the id of a <tanrei-tabs> in the same document or shadow root. swipeable is spelled as its inverse, no-swipe, because an HTML boolean attribute defaults to false.
SwiftUI, Compose, Flutter and React Native have not implemented the tab view yet. Until they do, a row of Tabs shows the selected panel there, as its own page describes, without the swipe.
<!-- Its own row (the default): each panel declares its tab. -->
<tanrei-tab-view label="Project">
<tanrei-tab-panel value="overview" label="Overview" icon="dashboard">…</tanrei-tab-panel>
<tanrei-tab-panel value="activity" label="Activity" icon="history">…</tanrei-tab-panel>
<tanrei-tab-panel value="billing" label="Billing" icon="payments" disabled>…</tanrei-tab-panel>
</tanrei-tab-view>
<!-- Linked, for tabs placed elsewhere (an app bar, a sticky header): the view draws no row. -->
<tanrei-tabs id="project-tabs" label="Project">
<tanrei-tab value="overview" label="Overview"></tanrei-tab>
<tanrei-tab value="activity" label="Activity"></tanrei-tab>
</tanrei-tabs>
<tanrei-tab-view tabs="project-tabs">
<tanrei-tab-panel value="overview">…</tanrei-tab-panel>
<tanrei-tab-panel value="activity">…</tanrei-tab-panel>
</tanrei-tab-view>
<!-- Views that name one group show one choice. remember-as keeps it on the device, under your own key. -->
<tanrei-tab-view variant="secondary" group="platform" remember-as="my-docs-platform" label="Platform">
<tanrei-tab-panel value="web" label="Web">…</tanrei-tab-panel>
<tanrei-tab-panel value="flutter" label="Flutter">…</tanrei-tab-panel>
</tanrei-tab-view>
<script type="module">
import '@tanrei/web/components/tab-view'; // defines the tabs and the panels too
// One change per selection. Own row: the view's, for a tab and for a swipe.
// Linked: the tabs' for a tab, the view's for a swipe. In a group: only the view the person used.
document.addEventListener('change', (e) => console.log(e.target.localName, e.detail.value));
</script>import { Tab, TabPanel, Tabs, TabView } from '@tanrei/react';
// Its own row: one onChange hears every selection, from a tab or a swipe.
<TabView label="Project" value={view} onChange={(e) => setView(e.detail.value)}>
<TabPanel value="overview" label="Overview" icon="dashboard"><Overview /></TabPanel>
<TabPanel value="activity" label="Activity" icon="history"><Activity /></TabPanel>
</TabView>
// Linked: the tabs announce a tab, the view announces a swipe. Update your state from both.
<Tabs id="project-tabs" label="Project" value={view} onChange={(e) => setView(e.detail.value)}>
<Tab value="overview" label="Overview" />
<Tab value="activity" label="Activity" />
</Tabs>
<TabView tabs="project-tabs" onChange={(e) => setView(e.detail.value)}>
<TabPanel value="overview"><Overview /></TabPanel>
<TabPanel value="activity"><Activity /></TabPanel>
</TabView>
// A shared choice, remembered. noSwipe is the spec's swipeable, inverted.
<TabView variant="secondary" group="platform" rememberAs="my-docs-platform" label="Platform" noSwipe>
<TabPanel value="web" label="Web"><WebSample /></TabPanel>
<TabPanel value="flutter" label="Flutter"><FlutterSample /></TabPanel>
</TabView>A secondary row that fills, with icons and a disabled tab; and a view linked to a row placed apart from it
Three services, all running.
Deployed 2 hours ago.
Billing is not set up.
Day 1: arrive, and walk the old town.
Four places saved near the station.
The museum is closed on Mondays.
Anatomy
| Part | Description |
|---|---|
1 tabs · optional | The row of tabs the Tab view draws above its container while it is not linked to a row placed elsewhere (TBV-16): a Tabs, whole (tab list, tabs, active indicator, underline and divider), drawn from the tabs' own tokens, with one tab for each panel, named and marked as the panel says. Linked to another row, the Tab view draws none (TBV-17). |
2 container | The region that holds the panels, below the row when the Tab view draws one: as wide as the Tab view and as tall as the shown panel. It draws nothing of its own, takes the swipe, and clips the panels to its corners while they travel and blur. The row is outside it and is never moved, faded or blurred by a switch. |
3 panel | One view, matched to the tab of the same value. While the Tab view draws its own row, the panel also says what its tab shows (a label, an icon) and whether it is disabled. One panel is shown at rest; during a switch the old and the new panel are drawn in the same place, the new one over the old. Its content is the product's. |
4 focus-ring | Gap-then-ring indicator shown for keyboard focus on a panel that holds nothing focusable, drawn inside the container's edge. |
Properties
| Property | Type | Default | Description |
|---|---|---|---|
value | string | null | The value of the selected tab, whose panel is shown. The Tab view keeps it equal to its tabs' value, whether it draws them or is linked to them. null shows the panel of the tab the tabs select while theirs is null (the first enabled tab). |
variant | primary | secondary | tonal | "primary" | The variant of the row the Tab view draws itself, with the Tabs' meaning: primary for the main views of a screen, secondary for views within one of them, tonal for the quiet row of a sample or a setting. A design system may add values to the tabs' axis, and the row takes them. Ignored while the Tab view is linked to a row placed elsewhere, which has its own. |
size | small | medium | "medium" | The size of the row the Tab view draws itself, with the Tabs' meaning: medium for the views of a screen, small for a row in a card's or a sample's header. A design system may add values to the tabs' axis, and the row takes them. Ignored while the Tab view is linked to a row placed elsewhere, which has its own. |
track | boolean | null | Whether the row the Tab view draws itself draws its track, with the Tabs' meaning: false draws none, true draws it, and null, the default, takes comp.tabs.track.visible, the system's choice. Ignored while the Tab view is linked to a row placed elsewhere, which has its own. |
fullWidth | boolean | false | How the row the Tab view draws itself lays out its tabs, with the Tabs' meaning: false (hug) sizes each tab to its label and scrolls the row when they do not fit; true (fill) shares the row's width among the tabs. The Tab view itself is always as wide as its parent. Ignored while the Tab view is linked to a row placed elsewhere. |
label | string | "" | The accessible name of the row the Tab view draws itself: what the views are views of. Needed when a screen has more than one row of tabs. Ignored while the Tab view is linked to a row placed elsewhere, which is named by its own label. |
tabs | string | "" | Empty (the default): the Tab view draws its own row of tabs above its panels, one per panel. Set: the Tab view is linked to a Tabs placed elsewhere (in an app bar, a sticky header) and draws no row; those tabs name its panels, move between them without a swipe and draw their indicator where a swipe has brought it. How a platform names the link is in its mapping. |
swipeable | boolean | true | Lets a person switch panels by dragging the shown panel sideways. False leaves the switch to the tabs alone, for panels whose content is itself dragged sideways (a map, a drawing surface). |
transition | auto | slide | fade | none | "auto" | The effect of switching views: the form in which the shown panel gives way to the next. slide: the panels travel, and fade and blur on the way, as the system's transition values say, and follow a swipe. fade: the two panels crossfade in place. none: the new panel is shown whole in one frame, and the row the Tab view draws puts its indicator on the new tab in that same frame (TBV-22, TBV-23). Auto takes comp.tab-view.motion.transition, the design system's answer (slide in the default system). Where motion is reduced slide is drawn as fade, and nothing is ever added to fade or none. It applies whether the Tab view draws its row or is linked to one; a linked row keeps its own transition. |
group | string | "" | The group of the row the Tab view draws itself, with the Tabs' meaning: Tab views and rows of tabs that name the same group show the same choice, and a view that has no panel of that value keeps its own. Empty (the default): the view's choice is its own. Ignored while the Tab view is linked to a row placed elsewhere, which names its own group. |
rememberAs | string | "" | The key under which the row the Tab view draws itself keeps a person's choice on their device and restores it before the view is first drawn, with the Tabs' meaning. Empty (the default): nothing is read or written. Ignored while the Tab view is linked to a row placed elsewhere, which names its own key. |
Events
| Event | Description |
|---|---|
change | The user selected another panel; carries its value, which value and the tabs' value now hold. The Tab view announces every selection made on its own row and every swipe. While it is linked, a selection made on the linked tabs is announced by their own change instead, so every selection is announced once. |
Items
The kinds of item the component repeats. A fixture lists its items in order; each platform names each kind in its own idiom.
panel
One view of the Tab view: the content shown while the tab with its value is selected, and, for the row the Tab view draws itself, what that tab shows.
| Property | Type | Default | Description |
|---|---|---|---|
value | string | "" | The value of the tab that shows this panel. Unique within the Tab view; required. |
label | string | "" | The text of this panel's tab in the row the Tab view draws itself, and the accessible name of both the tab and the panel. Required there, even when labelHidden is true; not used while the Tab view is linked, where the linked row's own tab names the panel. |
icon | string | "" | The name of an icon (a glyph of the system's icon set) for this panel's tab in the row the Tab view draws itself. Give every panel an icon, or none. Not used while the Tab view is linked. |
labelHidden | boolean | false | Draws this panel's tab as its icon alone and keeps label as its accessible name (an icon-only tab), in the row the Tab view draws itself. Not used while the Tab view is linked. |
disabled | boolean | false | Makes this panel's tab disabled in the row the Tab view draws itself: a person cannot select it, and a swipe skips its panel. Not used while the Tab view is linked, where the linked row's own tab says whether it is disabled. |
| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
|---|---|---|---|---|---|
| Item | tanrei-tab-panel | TanreiTabPanel | TanreiTabPanel | TanreiTabPanel | TabPanel |
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 | The same element the tabs' own panels are: <tanrei-tab-panel value="overview" label="Overview" icon="dashboard">, a child of tanrei-tab-view, with label-hidden and disabled attributes, as tanrei-tab has them. label, icon, label-hidden and disabled are read only by a tanrei-tab-view that draws its own row; a panel of a tanrei-tabs, or of a linked view, ignores them. | TanreiTabPanel(_ title:, icon:) { … }.tag(value) in the view's builder, as the tabs' TanreiTab is; .labelStyle(.iconOnly) is labelHidden and .disabled(_:) is disabled. A linked view's panels may omit the title: TanreiTabPanel { … }.tag(value). | TanreiTabPanel(value, label, icon, labelHidden, enabled) { … } called in the TanreiTabView { } content, a scope that collects the panels (as a lazy list's content does) so that the row can be drawn from their labels before a panel is composed. enabled is the inverse flag. | TanreiTabPanel<T>(value:, label:, icon:, labelHidden:, enabled:, child:) entries of the view's panels: list, as the bar's TanreiTab<T> entries are; enabled is the inverse flag. | <TabPanel value="overview" label="Overview" icon="dashboard">…</TabPanel> children of <TabView>; labelHidden and disabled as on the row's Tab. |
States
Behavior
Requirements in EARS form. Every platform implementation is tested against these ids.
| ID | Requirement |
|---|---|
TBV-1 | THE Tab view SHALL show the panel whose value is the value of its tabs' selected tab, its tabs being the row it draws itself (TBV-16) or the row it is linked to (TBV-17) (TAB-11: the first enabled tab while their value is null), and hide every other panel; THE Tab view SHALL keep its own value equal to its tabs' value, and WHEN either of them is set THE Tab view SHALL set the other to the same value without emitting change. |
TBV-2 | WHEN its tabs' value changes while their indicatorOffset is 0 (the user selected a tab, by activating it or by releasing a drag across the row on it, TAB-1, TAB-12 and TAB-29, or the product set it) THE Tab view SHALL switch from the shown panel to the newly selected tab's panel as TBV-5 and TBV-6 say, in the form its transition names (TBV-22); WHILE it is linked THE Tab view SHALL NOT emit change for that selection, which the linked tabs have announced themselves, and WHILE it draws its own row it SHALL announce it as TBV-18 says. WHERE their value changes because they follow a choice of their group that was made on another member, or one restored from storage, THE Tab view SHALL show the panel at once instead, as TBV-20 says. |
TBV-3 | WHILE swipeable is true, WHEN the user drags the shown panel by touch or pen past the platform's touch slop, further sideways than up or down, THE Tab view SHALL take the drag and track it with a progress p, the drag's sideways distance divided by the width of its container and kept between 0 and 1: toward the next enabled tab of its tabs when the drag moves toward the start edge, and toward the previous enabled tab when it moves toward the end edge. In every frame of the drag THE Tab view SHALL draw the switch to that tab's panel at p (TBV-5) and set its tabs' indicatorOffset to p toward the next tab or to −p toward the previous one; WHERE there is no enabled tab in the drag's direction THE Tab view SHALL leave the panel at rest and indicatorOffset at 0. 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. A drag that starts on the row the Tab view draws itself is the row's (its tabs are pressed, its active indicator follows the drag, TAB-28, and it scrolls sideways itself) and SHALL NOT be taken; WHILE its tabs follow a drag of their own, in its own row or in the linked row, THE Tab view SHALL take no drag and SHALL leave the shown panel at rest until that drag's release selects a tab (TBV-2): the indicator changes continuously under the finger, but which panel is shown is not decided before the release. |
TBV-4 | WHEN the user releases a drag the Tab view has taken THE Tab view SHALL commit it if p is at least 0.5 or the release is a fling toward the tab, and cancel it otherwise, a fling away from the tab always cancelling; 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 Tab view 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. To commit THE Tab view SHALL set its value and its tabs' value to that tab's value and emit change exactly once with it, re-express indicatorOffset from the new tab (1 − p toward the tab it left) in the same frame, and finish the switch while bringing indicatorOffset to 0 over comp.tab-view.motion.switch-duration with comp.tab-view.motion.switch-easing, starting from where the release left them. To cancel THE Tab view SHALL bring the switch back to p = 0 and indicatorOffset to 0 over the same motion and emit nothing. |
TBV-5 | WHILE a switch from panel A to panel B is at progress p THE Tab view SHALL draw both panels in the same place, B over A, with a travel D of comp.tab-view.transition.distance plus comp.tab-view.transition.distance-fraction times the width of its container: A moved p × D toward the edge it leaves by (the start edge when B's tab comes after A's among its tabs, the end edge otherwise), at an opacity of 1 − p × (1 − comp.tab-view.transition.opacity) and blurred by p × comp.tab-view.transition.blur; B moved (1 − p) × D from the opposite edge, at an opacity of 1 − (1 − p) × (1 − comp.tab-view.transition.opacity) and blurred by (1 − p) × comp.tab-view.transition.blur. The travel SHALL follow p as eased, an overshoot included; the opacity and the blur SHALL follow p clamped between 0 and 1. THE Tab view SHALL clip both panels to its container, its corners rounded by comp.tab-view.container.shape, so that neither travel nor blur draws outside it or over the row it draws itself; WHERE a platform cannot blur a view THE Tab view SHALL draw the travel and the fade without the blur and say so in its README. |
TBV-6 | WHEN a switch starts from a selection on its tabs or by the product (TBV-2) THE Tab view SHALL run p from 0 to 1 over comp.tab-view.motion.switch-duration with comp.tab-view.motion.switch-easing; WHEN the selection changes again, or the user starts a drag, before a switch has ended THE Tab view SHALL end that switch at once, as if it had finished, and start from the panel it switched to. WHEN the Tab view first appears, or its panels are added, removed or given other values, or it is linked or unlinked (TBV-17), THE Tab view SHALL show the selected tab's panel at once, without a switch. |
TBV-7 | WHERE motion is reduced THE Tab view SHALL draw every switch as TBV-5 says with comp.tab-view.transition.distance, comp.tab-view.transition.distance-fraction and comp.tab-view.transition.blur taken as 0, so that only the fade is left: during a drag the panels crossfade with p, and a switch from a selection, and the rest of a switch after a release, SHALL run over comp.tab-view.motion.fade-duration with comp.tab-view.motion.fade-easing. Its tabs' indicatorOffset SHALL still follow the drag, which the person's finger moves, and SHALL return to 0 at once on release. |
TBV-8 | THE Tab view SHALL NOT take a drag that starts on its own row or while its tabs follow a drag of their own (TBV-3), on content that handles sideways drags itself (a region that scrolls sideways, a slider, a carousel, a text field, selected text or its handles) or that is made by mouse, trackpad or wheel, and WHILE swipeable is false THE Tab view SHALL take no drag at all; in every such case the panels switch only through the tabs (TBV-2). |
TBV-9 | THE Tab view SHALL expose the shown panel with the tab panel role, named by its tab and associated with it where the platform can express that (the tab controls the panel; the panel is labelled by the tab), whether the tab is in the row it draws itself or in the row it is linked to, SHALL hide every other panel from assistive technology and from focus, including a panel that a switch or a drag still draws, SHALL move that exposure to the new panel when value changes rather than when the switch's drawing ends, and SHALL make a shown panel with no focusable content itself focusable so that Tab reaches it. |
TBV-10 | WHEN a switch hides the panel that holds keyboard focus THE Tab view SHALL move focus to its tabs' selected tab. WHEN a panel with no focusable content receives keyboard focus THE Tab view SHALL show the focus ring inside the container's edge, its corners following comp.tab-view.container.shape, as sys.behavior.focus-style names (ring or glow), without animation; focus gained by pointer or touch SHALL NOT show it; a platform that cannot draw a choice SHALL fall back to the first value and state that in its README. |
TBV-11 | THE Tab view SHALL be as wide as its parent and, unless the product sizes it through the platform's own sizing, as tall as the row it draws itself (none while it is linked) plus its shown panel; WHILE a switch draws two panels its container SHALL be as tall as the taller of them, and SHALL take the new panel's height at once when the switch ends. THE Tab view SHALL place its own row at its top edge, across its whole width, with the container directly below it and no space between them. WHILE the product sizes it THE Tab view SHALL keep its own row at its own height at the top, and make each panel fill the rest and scroll up and down inside it, below the row. |
TBV-12 | THE Tab view SHALL keep a hidden panel and its state (its scroll position, what was typed into it), so that it is shown as it was left, and SHALL NOT discard or recreate a panel because it is hidden, nor because its tab in the row it draws itself changes. |
TBV-13 | WHEN the layout direction is right-to-left THE Tab view SHALL mirror the drag and the travel with the reading order: a drag toward the start edge, which is the right, moves toward the next tab, and a panel leaves by the start edge when the tab it gives way to comes after its own; THE Tab view SHALL NOT mirror a panel's content. Its own row SHALL mirror as TAB-21 says. |
TBV-14 | WHEN tabs names a row that is not there (on a platform that links by an object, a link that no row uses) THE Tab view SHALL show the panel whose value equals the view's value (the first panel while the view's value is null), take no drag, expose no panel with the tab panel role, draw no row of its own (TBV-17), and in debug builds report that its panels have no tabs; WHEN the selected tab has no panel THE Tab view SHALL show none and in debug builds report the value. WHILE the Tab view draws its own row and holds no panel THE Tab view SHALL draw that row with no tab and in debug builds report that it has no panels. |
TBV-15 | WHEN a panel's value is empty, repeats another panel's, or, while the Tab view is linked, is the value of no tab of the linked row THE Tab view SHALL still hold it, show only the first panel with a given value, give its own row no tab for any other, and in debug builds report the panel; WHEN the linked row holds panels of its own THE Tab view SHALL show and associate its own panels in their place, keep the tabs' panels hidden, and in debug builds report the tabs' panels. |
TBV-16 | WHILE tabs is empty THE Tab view SHALL draw its own row of Tabs above its container (TBV-11) and be linked to that row as it would be to a row placed elsewhere: one tab for each panel that TBV-15 shows, in the panels' order, whose value, label, icon, labelHidden and disabled are the panel's; the row's variant, size, track, fullWidth, label, group and rememberAs SHALL be the Tab view's own, its transition what TBV-23 says, and its value the Tab view's value. The row SHALL be a Tabs in every respect, drawn from the tabs' tokens of its variant and size with every TAB behaviour (among them the press answer of TAB-8 to TAB-10, the keyboard of TAB-12, the names of TAB-13 and TAB-22, and the indicator of TAB-17 and TAB-26), and SHALL change in the same frame as the panels it is drawn from are added, removed, reordered or given another value, label, icon, labelHidden or disabled. |
TBV-17 | WHILE tabs names a row THE Tab view SHALL draw no row of its own, whether or not the named row is there (TBV-14), and SHALL use none of its variant, size, track, fullWidth, label, group and rememberAs or of its panels' label, icon, labelHidden and disabled, which the linked row's own props and tabs replace; WHEN tabs names a row while variant, size, track, fullWidth, label, group or rememberAs differ from their defaults THE Tab view SHALL in debug builds report that they apply only to a row it draws itself. WHEN tabs changes from empty to a row, or back, THE Tab view SHALL remove or draw its own row at once, keep its value (giving it to the newly linked row, or taking that row's while its own is null) and show the selected tab's panel at once (TBV-6). |
TBV-18 | WHILE the Tab view draws its own row, WHEN the user selects another tab on that row (TAB-1, TAB-12, TAB-29) THE Tab view SHALL set its value to that tab's value and emit change exactly once with it, in the same frame as the row sets its own value, and SHALL NOT let the row's own change reach the product as a second announcement; together with TBV-4 every selection a person makes on a Tab view with its own row, by a tab or by a swipe, SHALL be announced once, by the Tab view. |
TBV-19 | THE Tab view 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 Tab view is shown THE Tab view SHALL draw itself for the new direction at once, without a motion of its own, keeping the selected panel and its focus. |
TBV-20 | WHEN its tabs' value changes because their group's choice was made on another member or restored from storage (TAB-36, TAB-38, TAB-39) THE Tab view SHALL show the newly selected tab's panel at once, with none of the travel, fade and blur of TBV-5 and without the motion of TBV-6 or TBV-7, SHALL take that panel's height in the same frame (TBV-11), SHALL keep the panel it hides and its state (TBV-12), SHALL move the panels' exposure to assistive technology in that frame (TBV-9), and SHALL emit no change. WHERE that hides the panel that holds keyboard focus THE Tab view SHALL move focus to its tabs' selected tab as TBV-10 says, without scrolling anything to show it. WHERE the Tab view follows a swipe when the choice changes THE Tab view SHALL end the swipe without committing it, emit nothing for it, and show the chosen panel at rest. THE Tab view a person selected on SHALL switch as TBV-2 and TBV-6 say and announce the selection once (TBV-18, or the linked tabs' change). |
TBV-21 | WHEN a person commits a swipe (TBV-4) on a Tab view whose tabs are a member of a group and, in following that choice, other members change the size of what lies before the Tab view in a region that scrolls, THE Tab view SHALL keep its container where the person is using it, as TAB-40 says of a row: in the frame that first draws the new choice the container's top edge 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 the difference at once and without a scrolling motion, as far as each region can scroll. A selection made on a tab SHALL be kept in place by the row (TAB-40), whether the Tab view drew that row or is linked to it. |
TBV-22 | THE Tab view SHALL draw every switch between two panels in the form its transition names: the name transition gives, or, WHILE transition is auto, the name comp.tab-view.motion.transition holds (slide, fade or none); WHEN transition names a value the Tab view does not define THE Tab view SHALL treat it as auto and in debug builds report it. WHILE the form is slide THE Tab view SHALL switch as TBV-3 to TBV-6 say. WHILE the form is fade THE Tab view SHALL draw every switch as TBV-5 says with comp.tab-view.transition.distance, comp.tab-view.transition.distance-fraction, comp.tab-view.transition.blur and comp.tab-view.transition.opacity all taken as 0, so that the two panels crossfade in place: a switch from a selection, and the rest of a switch after a swipe is released, SHALL run over comp.tab-view.motion.fade-duration with comp.tab-view.motion.fade-easing, and during a swipe the panels SHALL crossfade with p (TBV-3). WHILE the form is none THE Tab view SHALL show the newly selected tab's panel whole in the frame the selection is made, with none of the travel, fade and blur of TBV-5 and no motion of TBV-6, SHALL take that panel's height in the same frame (TBV-11), SHALL keep the panel it hides and its state (TBV-12) and SHALL move the panels' exposure and keyboard focus as TBV-9 and TBV-10 say — the drawing TBV-20 gives a choice made elsewhere; a swipe SHALL still be taken while swipeable is true (TBV-3), SHALL leave the shown panel at rest for its whole length while its tabs' indicatorOffset follows the finger, and WHEN it is released SHALL be committed or cancelled as TBV-4 decides, the new panel shown at once and indicatorOffset brought to 0 at once. In every form THE Tab view SHALL select, emit and announce as it does under slide (TBV-4, TBV-18): the form changes what is drawn between two panels and nothing else. |
TBV-23 | WHERE motion is reduced THE Tab view SHALL draw the form slide as TBV-7 says, the form fade as TBV-22 says, and the form none as none: reduced motion SHALL never add a motion that transition took away. WHILE the Tab view draws its own row (TBV-16) and the form is none, THE Tab view SHALL give that row the tabs' transition none (TAB-43), so that the indicator is on the new tab in the frame the new panel is shown and nothing is left traveling over a view that has already changed; WHILE the form is slide or fade the row's transition SHALL be auto, the tabs' own answer (comp.tabs.motion.transition). THE Tab view SHALL NOT set the transition of a row it is linked to (TBV-17), which has its own. WHEN transition changes while a switch is drawn THE Tab view SHALL end that switch at once, as if it had finished (TBV-6), and SHALL draw the next one in the new form; changing transition SHALL NOT change value, the shown panel, keyboard focus or what is emitted. |
TBV-24 | WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE tab view 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 tab list of the row it draws itself (TBV-16), as the tabs of that row expose what is set on theirs — an id reference by the elements it names in the host's tree or a tree around it, a description after its own; WHILE label is not empty, label is the tab list's name, over a name given so; WHILE label is empty, a name given so is the tab list's name. 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 tab view SHALL take it off that element. WHILE the tab view is linked to tabs placed elsewhere (TBV-17) it draws no row, so what the product sets on its host reaches no element: THE tab view SHALL move it all the same and report it in debug builds, and the product sets it on the linked tabs instead. |
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-tab-view | TanreiTabView | TanreiTabView | TanreiTabView | TabView |
value · Property | value | selection | selectedValue | value | value |
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 |
label · Property | label | label | contentDescription | semanticLabel | accessibilityLabel |
tabs · Property | tabs | link | link | link | link |
swipeable · Property | noSwipe | swipeable | swipeable | swipeable | swipeable |
transition · Property | transition | transition | transition | transition | transition |
group · Property | group | selection | selectedValue | value | value |
rememberAs · Property | rememberAs | selection | selectedValue | value | value |
change · Event | change | selection | onSelectedValueChange | onChanged | onValueChange |
| Notes | Its own row: <tanrei-tab-view label="Project"> with tanrei-tab-panel children, and the attributes variant and full-width for the row. The view draws its row as a tanrei-tabs element of its own, placed as its first light-DOM child (in a tabs slot above the container) and marked as the view's, with one tanrei-tab per panel, and links itself to it as to any row. It is in the light DOM, not the shadow root, so that the tab's aria-controls and the panel's aria-labelledby point within one tree; the product does not edit it. The row's change stops at the view, which fires its own (TBV-18). Linked: <tanrei-tabs id="project-tabs"> … </tanrei-tabs> <tanrei-tab-view tabs="project-tabs">: tabs is the id of a tanrei-tabs in the same tree (as popovertarget is), and the tabsElement property sets the element itself; either one set means the view draws no row, even while the element is missing. The selected panel is the view's value, kept equal to its tabs' value in both directions; the view sets its own value before firing change, as a native control does. no-swipe is the inverse of swipeable, since an HTML boolean attribute defaults to false. The view writes its tabs' indicatorOffset property during a drag and its settle. The drag is Pointer Events on touch and pen with touch-action: pan-y on the container (not on the row), so vertical scrolling stays the browser's; the travel is a transform, the fade opacity and the blur filter: blur(), on the two panels only while a switch runs. The panels keep their place in the light DOM; a hidden one has hidden and inert. A shared choice: group and remember-as are attributes with the properties group and rememberAs, and the view writes both onto the tanrei-tabs it draws, which is the member of the group; a linked view has neither, and its tanrei-tabs carries them. A view that follows writes its panels' hidden and inert in the frame its row changes and starts no switch. For a committed swipe (TBV-21) the view measures and corrects its container's top as its row does for a tab (TAB-40). Sixty views of one group cost one read of localStorage, one write for a choice, and one measured element. size and track are forwarded to the tanrei-tabs the view draws, as variant is (track is true, false or null, with the attribute forms of the tabs'). transition is an attribute and a property (auto, slide, fade, none); auto reads --tanrei-comp-tab-view-motion-transition. fade runs the code path of prefers-reduced-motion (TBV-7) with the opacity end taken as 0; none runs the code path of a choice that came from the group (TBV-20) and sets transition="none" on the view's own row, removing it again when the form is no longer none. | Its own row: TanreiTabView(selection: Binding<V>, variant:, fullWidth:, label:, swipeable:) { TanreiTabPanel … }; the view draws a TanreiTabs above its panels and links it through a TanreiTabLink of its own. Linked: TanreiTabView(selection:, link: TanreiTabLink, swipeable:) { … }, a second initializer without the row's parameters, so that a view cannot be given both; the link is an observable object the product creates once and passes to its tabs too (TanreiTabs(…, link:)), through which the tabs report their order and disabled tabs and read the indicator offset. Writing the binding is change: a tab on the view's own row and a swipe write it; a linked row writes it through its own binding. The drag is a DragGesture on the panels that yields to vertical scrolling; the blur is .blur(radius:), converted from the token's standard deviation. SwiftUI's page-style TabView is a full-width pager without a tab row, so it is not this component. A shared choice has no parameter here: views given the same Binding show the same choice, and the product remembers it in @AppStorage, as the tabs' mapping says. A view whose selection changes that way switches as TBV-2 says for a value the product sets, with its motion: this platform cannot tell a view that follows from one the product set, so TBV-20 and TBV-21 are not reached here, and the README says so. Not built on this platform yet: size and track are the row's, with the Tabs' meaning and type; transition (.auto, .slide, .fade, .none) is the form of a switch (TBV-22): fade is the crossfade the view already draws where motion is reduced, none the change at once it already draws for a choice made elsewhere (TBV-20), and under none the view's own row is given the tabs' transition none (TBV-23). | Its own row: TanreiTabView(selectedValue, onSelectedValueChange, modifier, variant, fullWidth, contentDescription, swipeable) { TanreiTabPanel(…) { … } }; the view composes a TanreiTabs above its panels, linked through a link it remembers itself. Linked: an overload TanreiTabView(selectedValue, onSelectedValueChange, link, modifier, swipeable) { … } without the row's parameters, with val link = rememberTanreiTabLink() passed to TanreiTabs(…, link = link) too; the tabs read the indicator offset from it as a frame-time lambda, so a drag does not recompose the row. State hoisting: the caller keeps selectedValue; with its own row the view calls onSelectedValueChange for a tab and for a swipe, and linked the caller moves it from both components' callbacks. Not HorizontalPager: the travel is a short offset, not a scroll, and hidden panels stay composed (TBV-12). The blur is Modifier.blur, which draws nothing below Android 12; there the switch has no blur, as the README says. A shared choice has no parameter here: views given the same hoisted selectedValue show the same choice, and the caller remembers it in its own store, as the tabs' mapping says. A view whose selectedValue changes that way switches as TBV-2 says for a value the product sets, with its motion: this platform cannot tell a view that follows from one the product set, so TBV-20 and TBV-21 are not reached here, and the README says so. Not built on this platform yet: size and track are the row's, with the Tabs' meaning and type; transition (Auto, Slide, Fade, None) is the form of a switch (TBV-22): fade is the crossfade the view already draws where motion is reduced, none the change at once it already draws for a choice made elsewhere (TBV-20), and under none the view's own row is given the tabs' transition none (TBV-23). | Its own row: TanreiTabView<T>(value:, onChanged:, variant:, isScrollable:, semanticLabel:, swipeable:, panels: [TanreiTabPanel<T>(…)]); the view builds a TanreiTabBar<T> above its panels, linked through a TanreiTabLink<T> of its own. isScrollable is the inverse of fullWidth and defaults to true (hug), as on the bar. Linked: TanreiTabView<T>.linked(link:, value:, onChanged:, swipeable:, panels:), a named constructor without the row's parameters, with the TanreiTabLink<T> the product creates and passes to TanreiTabBar(…, link:) too, as Flutter's TabController is shared by a TabBar and a TabBarView. It replaces the existing TanreiTabBarView, which named only the content; its semanticLabels give way to the tabs, through which each panel is named by its tab with Flutter's tab panel role. The blur is an ImageFiltered blur, converted from the token's standard deviation. A shared choice has no parameter here: views given the same value from one piece of the product's state show the same choice, and the product remembers it in its own store, as the bar' mapping says. A view whose value changes that way switches as TBV-2 says for a value the product sets, with its motion: this platform cannot tell a view that follows from one the product set, so TBV-20 and TBV-21 are not reached here, and the README says so. Not built on this platform yet: size and track are the row's, with the Tabs' meaning and type; transition (auto, slide, fade, none) is the form of a switch (TBV-22): fade is the crossfade the view already draws where motion is reduced, none the change at once it already draws for a choice made elsewhere (TBV-20), and under none the view's own row is given the tabs' transition none (TBV-23). | Its own row: <TabView value onValueChange variant fullWidth accessibilityLabel swipeable> with <TabPanel> children; the view renders a <Tabs> above its panels from their props, linked through a link of its own. Linked: <TabView value onValueChange link swipeable>, where const link = useTabLink() is passed to <Tabs link={link}> too and carries the indicator offset as an animated value, so a drag does not re-render the row; the props type is a union, so that link and the row's props cannot be given together. Controlled: with its own row the view calls onValueChange for a tab and for a swipe; linked, the caller moves value from both components' callbacks. React Native has no tab panel role and cannot blur a view without a native module, so the switch draws its travel and fade only, as the README says. A shared choice has no prop here: views given the same controlled value show the same choice, and the product remembers it in its own store, as the tabs' mapping says. A view whose value changes that way switches as TBV-2 says for a value the product sets, with its motion: this platform cannot tell a view that follows from one the product set, so TBV-20 and TBV-21 are not reached here, and the README says so. Not built on this platform yet: size and track are the row's, with the Tabs' meaning and type; transition ("auto", "slide", "fade", "none") is the form of a switch (TBV-22): fade is the crossfade the view already draws where motion is reduced, none the change at once it already draws for a choice made elsewhere (TBV-20), and under none the view's own row is given the tabs' transition none (TBV-23). |
Tokens
| Token | Aliases | Resolved (light) |
|---|---|---|
--tanrei-comp-tab-view-container-shape | sys.shape.corner.none | 0px |
--tanrei-comp-tab-view-motion-fade-duration | sys.motion.role.reduced | 100ms |
--tanrei-comp-tab-view-motion-fade-easing | sys.motion.easing.state | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-tab-view-motion-switch-duration | sys.motion.role.selection.travel | 250ms |
--tanrei-comp-tab-view-motion-switch-easing | sys.motion.easing.spatial | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-tab-view-motion-transition | — | 0 |
--tanrei-comp-tab-view-transition-blur | — | 8px |
--tanrei-comp-tab-view-transition-distance | — | 40px |
--tanrei-comp-tab-view-transition-distance-fraction | — | 0 |
--tanrei-comp-tab-view-transition-opacity | — | 0 |
Fixtures
Canonical renderings. Every platform snapshots each fixture under the same id so the results can be compared.
variant: primary (13)
default
label: Project items: 3 second-selected
value: activity label: Project items: 3 icons
label: Project items: 3 disabled-tab
label: Project items: 3 tall-panel
value: settings label: Project items: 3 linked
tabs: project-tabs items: 3 missing-panel
value: activity tabs: project-tabs items: 2 shared-choice
value: flutter label: Platform group: fixture-platform items: 4 shared-choice-lacking
value: react label: Platform group: fixture-platform items: 2 primary-small
size: small label: Project items: 3 slide
label: Project transition: slide items: 3 fade
label: Project transition: fade items: 3 no-transition
label: Project transition: none items: 3 variant: secondary (1)
secondary-fill
variant: secondary fullWidth: true label: Project items: 3 variant: tonal (2)
tonal
variant: tonal label: Project items: 3 tonal-no-track-no-transition
variant: tonal track: false label: Platform transition: none items: 4 Generated from specs/tab-view.spec.json, tokens/comp/tab-view.tokens.json, design/tokens/
Use a tab view when each tab is a peer view of one subject and people move between them often: a project’s Overview, Activity and Settings, or the same sample for several platforms. It adds three things to a row of tabs: one declaration for a tab and its panel, the swipe, and a switch that the indicator and the panels make together.
A row of Tabs with its own panels is enough when the panels are plain content and nobody will swipe them. A tab view is not a carousel and not a pager: it shows one panel per tab, and a swipe moves one panel. A row of items to browse belongs to the carousel.
| Form | What you write | change comes from |
Use for |
|---|---|---|---|
| Its own row (default) | Panels only. Each panel names its tab with label and icon. |
The view, for a tab and for a swipe. | Most tab views: the tabs sit directly above their content. |
Linked (tabs) |
A row of Tabs elsewhere, and a view of panels that names it. | The tabs for a tab, the view for a swipe. | Tabs that sit apart from their content, such as in a sticky header. |
- A linked row’s own props decide its look, its name, its group and its key.
variant,size,track,fullWidth,label,groupandrememberAson a linked view are unused, and debug builds report one that is set. - Place a linked view after its tabs in reading order, so that Tab moves from the selected tab into the panel. A view with its own row does that by construction.
- Tabs have no badge, so neither has a panel. To mark a tab with a badge, write the row yourself and link it.
- One panel per swipe. A drag by touch or a pen, further sideways than up or down, moves toward the next or the previous enabled tab, and the indicator follows the finger. Released past halfway, or with a fling toward the tab, it commits; otherwise it returns. A mouse, a trackpad and a wheel do not swipe: they use the tabs.
- Disabled tabs are skipped, and at the first or last tab the panel stays at rest.
- Content that is dragged sideways keeps its drag. A swipe is not taken from a region that scrolls sideways, a slider, a carousel, a text field or selected text. For a panel that is dragged sideways as a whole, such as a map or a drawing surface, set
swipeableto false and leave the switch to the tabs. - A drag across the row is the tabs’ own gesture: it can pass several tabs, the shown panel waits, and the release switches to the chosen tab’s panel.
- The switch is the design system’s. By default it is a short travel with a fade and a blur; a system can make it a full-width page turn. Where motion is reduced, only the fade is left.
transition names the form in which the shown panel gives way to the next.
| Value | What it does |
|---|---|
slide |
The panels travel as the design system says, and follow a swipe. |
fade |
The two panels crossfade in place. |
none |
The new panel is shown whole in one frame. The row the view draws puts its indicator on the new tab in that same frame. |
auto (the default) |
Takes the design system’s answer (comp.tab-view.motion.transition, slide in the default system). |
Use none for panels that are read and not watched, such as code. A swipe is still taken under none: the panel stays at rest, the indicator follows the finger, and the release shows the new panel at once. To turn the swipe off, set swipeable to false. Where motion is reduced, slide is drawn as fade, and nothing is added to fade or none. A row the view is linked to keeps its own transition.
Two tab views of one group: choose a tab in either
Install the package with npm.
Install the package with pnpm.
Install the package with Yarn.
Run the build with npm.
Run the build with pnpm.
- Give every view that answers the same question the same
group. A view’s own row is the member, so everything the Tabs page says of a group holds: the choice is matched by a panel’svalue, never by its label, and a view that has no panel of that value keeps its own. In the demo, Yarn exists in the first view only. - Only the view a person used runs a switch and emits
change. Every other view shows its panel at once, with no travel, fade or blur, keeps the state of the panels it hides, and takes no focus. - The view a person used stays where it is in the window while views above it change height.
rememberAsnames the key under which the choice is kept on the person’s device and restored before the view is first drawn. Nothing is stored without a key, and only the panel’s value is stored. On the web the key is a key oflocalStorage, used exactly as given. The demo names none.- A linked view uses neither prop: the linked row names its own group and key.
- The view is as wide as its parent and as tall as its row plus the shown panel. Panels of unequal height are normal, so what follows a view moves when its panel changes.
- Give the view a height of its own when nothing below it should move. The row stays at the top, and each panel fills the rest and scrolls inside it.
- A hidden panel stays in place with its state: what was typed into it and how far it was scrolled. The view has no lazy mode: render a costly panel’s content when its tab is first selected.
- Give every panel a
label, also whenlabelHiddenshows its icon alone: it names the tab and, through it, the panel. - Give every panel an icon, or none.
- Keep each panel’s
valueunique in its view and stable while the screen is shown. - Name the view with
labelwhen a screen has more than one row of tabs.
- Don’t nest a tab view of the same variant in a panel. Put secondary tabs inside a view of primary tabs.
- Don’t rely on the swipe to show that there are more views. The tabs say so; the swipe is a shortcut.
- Don’t edit the row a view draws for itself. Change the panels, and the row follows.
- Don’t use a tab view to move between the destinations of an app. That is the navigation bar’s or drawer’s job.
- Role
tabpanel- Labeling
- The row the Tab view draws itself is a tab list named by the Tab view's label, each tab named by its panel's label. Each panel is named by the tab of the same value, in the Tab view's own row or in the linked row, and each tab controls its panel. The Tab view itself is not named: it has no role of its own beyond its row and its panels.
- 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 from the selected tab (in the Tab view's own row, or in the linked row) into the shown panel (its first focusable element, or the panel itself when it holds none), and back. |
| Right Arrow / Left Arrow, Home / End (on a tab) | Select another tab (TAB-12), and the Tab view switches to its panel. The Tab view adds no keys of its own: inside a panel the arrow keys belong to its content. |
Notes
- The Tab view has no target of its own: its own row's tabs meet the minimum target as every tab does (TAB-5), and its panels hold the product's controls, which meet it themselves.
- The swipe is never the only way (WCAG 2.5.1): every panel is reached by a single tap on its tab, and by the arrow keys on the tabs. With a screen reader running, a one-finger swipe belongs to the reader; the tabs remain the way to switch panels.
- The swipe of the view and the drag across its tabs are one gesture in two places (TBV-3, TBV-4; TAB-28 to TAB-30): the tabs' active indicator follows the finger, the selection is made on release, from halfway or by a fling, a cancelled gesture returns the indicator, nothing is announced before the release, and under reduced motion the indicator still follows the finger and lands at once. They differ in reach. A swipe of the view moves to the neighbouring panel, and the panels travel with the finger. A drag across the row can pass several tabs, so the panels wait: the shown panel stays at rest during the drag, and the release switches to the chosen tab's panel as any selected tab does (TBV-2, TBV-6), its switch and the indicator's settling running over equal times.
- One declaration makes the tab and its view: while the Tab view draws its own row, each panel carries its tab's label, icon, labelHidden and disabled, so the tabs and the panels cannot fall out of step. Its row is a Tabs, not a look-alike: the same tokens, the same variants and the same behaviours, so a system that restyles the tabs restyles the Tab view's row too.
- Linked, for tabs placed elsewhere: a product whose tabs sit in a top app bar or a sticky header, apart from their content, sets tabs to name that row, and the Tab view draws none. The linked row names the panels, and its own props decide its look. The Tab view must then follow its tabs in reading order, so that Tab moves from the selected tab into the panel; a Tab view with its own row meets that by construction.
- Selection belongs to the Tab view and its tabs together: they hold the same value, and each selection is announced once. With its own row, the Tab view announces every selection (TBV-18). Linked, the component the person used announces it: the linked tabs' change for a tab, the Tab view's change for a swipe; a product that keeps the selection itself updates it from both.
- The panels' role, name and association are the Tab view's; the tabs' own panels (TAB-13) are for a row without a Tab view. Web: `tanrei-tab-panel` children of `tanrei-tab-view` get role tabpanel, aria-labelledby and the tab's aria-controls, and the view's own row is in the same tree as its panels so that these references resolve. SwiftUI, Compose, Flutter and React Native have no association between a tab and its panel; there each panel is named by its tab (Flutter exposes its tab panel role) and follows the row in reading order, and each README says so.
- A panel that is not shown stays in place, hidden, with its state. A product whose panels are costly renders their content when their tab is first selected; the Tab view has no lazy mode of its own yet (it can be added as a prop without breaking this spec).
- A tab of the Tab view's own row carries what a tab of the Tabs carries, and nothing more: the Tabs have no badge, so neither does a panel. A product that marks a tab with a badge writes its row itself and links it; a badge on the Tabs would reach the panel as one more tab prop.
- Tab view is not a carousel and not a pager: it shows one panel at a time, one per tab, and a person moves one panel per swipe. A row of items to browse belongs to the carousel.
- How a switch looks is a value decision of the active design system (tokens.md): a short travel with a fade and a blur by default, a full-width page turn in a system that pages.
- A Tab view in a group (group) is the usual home of a shared choice: the same sample in several languages or platforms, many times on one page. Its own row is the member (TBV-16), so everything the Tabs say of a group holds: the choice is matched by value, the views that follow change silently and at once and keep their hidden panels' state (TBV-20), the row the person used stays where it is on the screen (TAB-40, TBV-21), and remembering is off unless the product names a key (TAB-39).
- Panels of unequal height are the normal case for samples, and the view is as tall as its shown panel (TBV-11). What follows a view therefore moves when its panel changes; what a person is using does not. A product that wants no movement at all below a view sizes the view itself, and the panels then scroll inside it (TBV-11).
- The effect of switching views has two parts with two owners: the panels' change is the Tab view's (transition: slide, fade or none; TBV-22), and the indicator's travel to the new tab is the row's (the tabs' transition: slide or none; TAB-43). Set on a Tab view that draws its row, none covers both (TBV-23), so that "no effect" means that nothing moves; fade and slide leave the row to the system. A row placed by the product, linked or alone, is set by its own prop.
- transition changes what is drawn and never what is told: the selection, the panel's exposure and the announcement are made in the same frame in every form (TBV-9, TBV-22). None is the calm choice for panels that are read rather than watched — a code sample in several languages, a table in several units — and for a page that holds many views sharing one choice.
- Under fade the panels finish (comp.tab-view.motion.fade-duration) before the row's indicator lands (comp.tabs.motion.indicator-duration), as they do where motion is reduced; the rule that the switch and the indicator land together holds for slide.
- A swipe under none still switches panels and still shows the finger its progress, on the row's indicator; the panels wait for the release. A product that wants no swipe sets swipeable to false, which is a separate decision.
Generated from specs/tab-view.spec.json, tokens/sys/measure.tokens.json
- The view’s own row is a tab list named by the view’s
label, and each tab is named by its panel’slabel. The view itself has no role. - The shown panel is a tab panel named by its tab and associated with it, in its own row or in the linked one. Every other panel is hidden from assistive technology and from focus, including one that a switch or a swipe still draws. The exposure moves when the selection does, not when the motion ends.
- Tab moves from the selected tab into the shown panel: to its first focusable element, or to the panel itself when it holds none. Such a panel shows the focus ring inside its edge, for keyboard focus only.
- When a switch hides the panel that holds focus, focus moves to the selected tab.
- The view adds no keys of its own. The arrow keys, Home and End on a tab select another tab, and the view switches. Inside a panel the arrow keys belong to its content.
- The swipe is never the only way: every panel is reached by a tap on its tab and by the arrow keys. With a screen reader running, a one-finger swipe belongs to the reader, and the tabs remain the way to switch.
- Nothing is announced before a swipe is released, and a cancelled swipe changes nothing.
- A view that follows its group changes silently: nothing is announced, and focus stays where the person is. If focus was in a panel that the change hides, it moves to that view’s selected tab.
- In right-to-left layouts the swipe and the travel mirror with the reading order. A panel’s content is not mirrored by the view.
- Debug builds report a
tabsthat names no row, a selected tab without a panel, a view with its own row and no panel, a panel whose value is empty or repeated, a linked row that holds panels of its own, and row props set on a linked view.
Known platform gaps:
- SwiftUI, Compose, Flutter and React Native have not implemented the tab view.
- Web: every panel is a scroll container, so the focus ring of a control at a panel’s very edge is clipped.
- Web: the behaviour tests run in Chromium only, and the swipe with synthetic pointer events only.
- Give every panel a
label, and the view alabelwhen a screen has more than one row of tabs. - Place a linked view after its tabs in reading order.
- Linked, update your own state from both
changeevents: the tabs’ and the view’s. - Set
swipeableto false for a panel that is itself dragged sideways. - Give the views of one group the same labels for the same values, and where they are far apart, say near the first one that the choice applies to the whole page.
- Choose the
rememberAskey yourself, and name it in your privacy notice where your product needs one.