Skip to content

PlTabs

One set of panels, one of which is shown. The indicator slides from the tab you left to the one you chose.

React
tsx
import { PlTab, PlTabPanel, PlTabs } from 'plass-ui';

<PlTabs defaultValue="account">
  <PlTab value="account">Account</PlTab>
  <PlTab value="billing">Billing</PlTab>

  <PlTabPanel value="account">Your name and your avatar.</PlTabPanel>
  <PlTabPanel value="billing">Cards and invoices.</PlTabPanel>
</PlTabs>;

The tabs and the panels are written as siblings and sorted apart by the component. There is no <PlTabList> to remember, and no array-of-subtrees prop. A panel is a subtree, and there is no useful shape for that which is not just children.

dart
import 'package:plass_ui/plass_ui.dart';

PlTabs<String>(
  value: tab,
  onChanged: (String next) => setState(() => tab = next),
  tabs: <PlTab<String>>[
    PlTab<String>(
      value: 'account',
      label: const Text('Account'),
      panel: const Text('Your name and your avatar.'),
    ),
    PlTab<String>(
      value: 'billing',
      label: const Text('Billing'),
      panel: const Text('Cards and invoices.'),
    ),
  ],
);

A tab and the panel it opens are one description, which is the whole of the difference: there is no PlTabPanel to keep in step, no third value to match up, and a panel that is not chosen is never built.

Props

PropTypeDefaultDescription
variantshared'solid' | 'glass' | 'ghost''glass'What the tab **bar** is made of. solid rides a clear pane in a groove, glass runs the indicator along a rule at the edge, ghost drops the rule
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Tab height and type scale — the same ladder as PlButton
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
orientationsharedPlassResponsive<'horizontal' | 'vertical'>'horizontal'Which way the bar runs. vertical puts the tabs down the side and moves the arrow keys onto the other axis
valuestring | number | nullThe chosen tab. Use with onValueChange for a controlled set
defaultValuestring | number | nullWhich starts chosen
onValueChange(value: string | number | null) => voidCalled with the new value
activateOnFocusbooleanfalseWhether the arrow keys also choose the tab they land on. Off by default — the moment one panel fetches, walking past four tabs fires four requests
loopFocusbooleantrueWhether the arrow keys wrap from the last tab back to the first
fullWidthbooleanfalseThe tabs share the bar's full width, each taking an equal part of it
wheelbooleantrueTurns a vertical wheel over a bar with more tabs than room into travel along it. A bar whose tabs all fit is left alone
overscrollshared'auto' | 'contain''contain'What the bar does with a wheel it has run out of tabs for. contain keeps it, auto hands it to the page
childrenReactNodeThe PlTab and PlTabPanel children. The component sorts the two apart itself
PropTypeDefaultDescription
tabs * List<PlTab<T>>The tabs, as a list of descriptions rather than children, each carrying its own panel
value * T?The chosen tab. null is a bar with nothing chosen
onChangedValueChanged<T>?Called with the new value
variantsharedPlassVariantPlassVariant.glassWhat the tab **bar** is made of. solid rides a clear pane in a groove, glass runs the indicator along a rule at the edge, ghost drops the rule
sizesharedPlassSizePlassSize.mdTab height and type scale — the same ladder as PlButton
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardPadding only — never the height, never the type scale
orientationsharedPlassResponsive<PlassOrientation>PlassOrientation.horizontalWhich way the bar runs. vertical puts the tabs down the side and moves the arrow keys onto the other axis
fullWidthboolfalseThe tabs share the bar's full width, each taking an equal part of it
wheelbooltrueTurns a vertical wheel over a bar with more tabs than room into travel along it. A bar whose tabs all fit is left alone
overscrollsharedPlassOverscrollPlassOverscroll.containWhat the bar does with a wheel it has run out of tabs for. contain keeps it, auto hands it to whatever is behind the bar
semanticLabelString?The name a screen reader gives the bar. It has no visible label of its own
focusNodeFocusNode?Drives the bar's one focus stop from outside
autofocusboolfalseTakes focus as it is inserted into the tree

The bar and the panels take a value of string | number.

The bar is generic in its tab's type (PlTabs<String>, PlTabs<Section>), so value and onChanged are typed rather than dynamic, and it is controlled, like every other control in the package. value is nullable: null is a bar with nothing chosen and no panel under it.

PlTab

PropTypeDefaultDescription
value * string | numberIdentifies the tab, and picks out the panel with the same value
startIconReactNodeContent before the label. Sized in em, so it tracks the label
endIconReactNodeContent after the label — a count, a status dot
disabledbooleanfalseUnavailable, but still listed
childrenReactNodeThe tab's label
PropTypeDefaultDescription
value * TIdentifies the tab, and picks out the panel with the same value
labelWidget?The tab's label
startIconWidget?Content before the label, drawn at 1.2× it so it tracks the label's size
endIconWidget?Content after the label — a count, a status dot
disabledboolfalseUnavailable, but still listed
panelWidget?What is shown under the bar when this tab is chosen. Only the chosen panel is built

PlTabPanel

PropTypeDefaultDescription
value * string | numberWhich tab shows this panel
keepMountedbooleanfalseKeeps the panel in the DOM while it is hidden. For one that is expensive to build, or that holds form state
childrenReactNodeThe panel's content

PlTabPanel is not in the Flutter package yet.

variant, size, density and orientation are read from the PlTabs around them. A tab that could disagree with its neighbours about any of those is a tab bar with a hole in it.

A tab is a PlTab, a description rather than a widget, for the reason a segment is one: the bar owns the roving focus, the arrow keys and the indicator that slides between the tabs, so it has to know which one is chosen and where each one is. Its panel rides along, because a tab and what it opens are the same fact written twice otherwise.

It carries no variant, no size, no density and no orientation, and could not. A tab that disagrees with its neighbours about any of those is a tab bar with a hole in it.

What the shared axes (variant size color density orientation) mean across the library is in prop conventions.

Tabs or a segmented button

Tabs swap whole panels of content. A segmented button filters what is already on screen. That is also why the solid tile here is a pane of clear glass rather than the family's gradient. The gradient tile belongs to the segmented button, and a screen with both should be able to tell them apart.

Examples

variant

glass is the classic bar: a rule along its edge with the indicator riding on it. solid is a groove with a pane sliding in it. ghost is the same bar with the rule taken away, for tabs inside a PlCard that already has an edge of its own.

React

orientation

vertical puts the tabs down the side and the panel beside them, and moves the arrow keys onto the other axis, which is Base UI's doingwhich the bar does itself, and is what makes a vertical tab bar reachable.

It is responsive, so a set can run one way on a phone and the other on a laptop. A server renders the xs entry and the browser corrects it on hydration.It is resolved against the window's width during build, so the first frame is already right. See breakpoints.

React

fullWidth

React

size

A tab is a control, so it takes the control height ladder. A md tab and a md PlButton are the same 40px, which is what lets a tab bar sit in a toolbar next to one without the row losing its baseline.

React

A bar with more tabs than room

A bar with more tabs than room scrolls rather than wrapping: a tab bar on two lines has stopped being a bar, and the indicator has nowhere sensible to sit.

Which is why the bar has to say it is scrolling, and a scrollbar does not. On a Mac it is an overlay that appears only while the strip is moving, and a reader decides whether there is more to look at the rest of the time. On Windows the same bar is fifteen pixels of permanent furniture under a row of labels. Both are taken away, and the end that still has tabs behind it is faded out instead. Only that end, so a faded edge always means there is more.

The fade takes the pixels away rather than painting over them, so it is right whatever the bar is sitting on. A component cannot know whether it is on the page, on a PlCard or on a tinted section, and a gradient painted in the wrong colour would be worse than no signal at all. It is dropped while a tab inside is showing a focus ring, because focusing a tab scrolls it flush against the edge the fade is strongest at.

Whether a bar overflows depends on the room it was given, so this is measured rather than declared. There is no prop for it.

The state is published as data-overflow on the tab list. none, start, end or both, in the reader's order, so a page can style against it or assert on it.

React

A bar that scrolls answers the wheel. A mouse has one wheel and it points down the page, which is the one direction the bar does not run in, so a vertical wheel over an overflowing bar moves it along instead — the reader who can see there are more tabs has a way of reaching them that is neither the scrollbar the bar deliberately does not draw nor the arrow keys, which also change the selection. wheel turns that off, and a bar whose tabs all fit never takes the gesture at all.

overscroll decides what happens after the last tab. contain, the default, keeps the wheel on the bar, so working along a long row does not end with the page moving under the reader. auto hands it back at the ends, holding only the gesture that was already moving the bar.

tsx
<PlTabs wheel={false} overscroll="auto">
dart
PlTabs<String>(wheel: false, overscroll: PlassOverscroll.auto, tabs: tabs);

Controlled

React

Accessibility

  • Base UI owns everything that makes a tab bar a tab bar rather than a row of buttons: roving focus so the whole bar is one tab stop, the arrow keys on whichever axis it runs, Home and End, the tab / tabpanel roles, and the aria-controls wiring between them.
  • activateOnFocus is off by default. Automatic activation is only kind when every panel is already on the page; the moment one of them fetches, walking past four tabs fires four requests.
  • A panel with nothing focusable inside takes focus itself, so its content is reachable from the keyboard.
  • The focus ring on a tab is drawn inset, because an offset ring on a tab inside a solid groove would be painted over its neighbours.
  • The indicator animates left, top, width and height rather than a transform. It is an empty box: nothing with text in it moves.
  • A bar with more tabs than room scrolls rather than wrapping, and fades the end that still has tabs behind it. A tab bar on two lines has stopped being a bar, and the indicator has nowhere sensible to sit.
  • One focus stop for the whole bar: exactly one tab is in the tab order and the rest are wrapped in an ExcludeFocus. That is what makes a bar a bar rather than a row of buttons.
  • on a horizontal bar and on a vertical one move the choice, wrapping at both ends and stepping over a disabled tab. Enter and Space choose the focused one.
  • Each tab is announced as one of a mutually exclusive set, chosen or not, and the bar is a container you can give a semanticLabel. Give it one. A bar has no visible label of its own.
  • Moving the focus moves the choice, because only the chosen panel is built and a bar that let focus and content disagree would be showing one thing and reading another. If a panel is expensive, keep the work out of build rather than out of the tab.
  • A tab's focus ring turns inward, because a ring drawn outside a tab in a solid groove would be painted over its neighbours.
  • The indicator animates its box, position and size, rather than a transform. It is an empty rectangle: nothing with text in it moves. With animations turned off at the OS it jumps.
  • A bar with more tabs than room scrolls rather than wrapping, and fades the end that still has tabs behind it. It used to be as wide as its tabs and overflow its box, and the advice was to wrap it yourself, which does not work: a SingleChildScrollView around a PlTabs scrolls the panel with the bar.

Differences from the React build

ReactFlutterWhy
<PlTab> and <PlTabPanel> childrentabs, as descriptions, each carrying its panelThe bar owns the roving focus, the arrow keys and the sliding indicator, so it has to know which tab is chosen and where each one is. Pairing the panel with its tab removes the third place the value had to match.
defaultValue / onValueChangevalue / onChangedFlutter's own controls are controlled, and its name for the callback.
a value of string | numbera generic TDart has generics, so the type is checked rather than restrained by convention.
every panel rendered, one shownonly the chosen panel builtA tab that is not open costs nothing. It also means a panel loses its state when you leave it, hold that state above the bar.
activateOnFocusMoving focus moves the choice, always, because the panel is built from the choice.
aria-labelsemanticLabelFlutter's name.
the tab / tabpanel roles, aria-controlsa mutually exclusive selected node, and one panelFlutter names the state on the node itself; there is no id to point at.
className, styleThere is no class list and no style attribute to pass through.

Released under the MIT License