Tabs
Tabs put several views of the same subject in one place and show one at a time. Reach for them when the panels are peers — sections of a settings screen, ranges of one report — and when the reader can afford to lose sight of the other panels. If the content needs to be compared side by side, or if switching changes what the page is rather than what it shows, use links and separate routes instead.
Import
import { Tabs } from "@forte-ui/react";Tabs is a namespace of five parts: Tabs.Root, Tabs.List, Tabs.Tab, Tabs.Indicator and Tabs.Panel. Tabs.Indicator renders inside Tabs.List; Tabs.Panel renders outside it, as a sibling of the list. Tabs.List renders a ScrollArea around itself unless overflow="visible" is set on the root — its own className, ref and props still land on the tablist element, but it is no longer the outermost box of the strip.
Each Tabs.Tab is paired with the Tabs.Panel that carries the same value. That pairing is not cosmetic — it is what generates the aria-controls and aria-labelledby attributes linking the two, so every tab needs a panel with a matching value and every panel needs a tab. value can be any string or number; Tabs.Root takes defaultValue (uncontrolled) or value plus onValueChange (controlled).
Examples
Line
The default variant="line" is the demo at the top of this page: a rail under the strip, with a 2px underline that slides along it. A line tab is flush with the rail, so it is rounded only on the two corners away from it — rounding all four would leave its focus ring curving off a line it is touching. Hovering a tab brightens its label and icon; neither variant paints a fill behind it. Use it for page- or section-level navigation, where the tabs sit above a large body of content.
Pill
variant="pill" turns the strip into a segmented control: the rail disappears, the list itself takes a surface, and the indicator grows into a filled block that slides behind the active label. Use it for small, mutually exclusive choices inside a larger view — a date range, a unit toggle.
The variant is set once on Tabs.Root and reaches the list, tabs and indicator through context. There is deliberately no way to express a mismatched pair such as a pill list with a line indicator.
Vertical
orientation="vertical" on Tabs.Root stacks the tabs down the side and puts the panel beside them. The rail moves to the inline-end edge, the indicator travels on the block axis, and the arrow keys that drive the strip change from Left/Right to Up/Down.
Overflow
A strip with more tabs than room scrolls rather than spilling out of its container. Tabs.List wraps itself in a ScrollArea, so the overflowing end fades out under a mask that opens and closes with the scroll position, a swipe or a drag brings it back, and overscroll-behavior: contain stops that swipe dragging the page sideways once the strip hits its end. The scroll area also inherits the strip's orientation, which switches its other axis off entirely — so a vertical wheel or trackpad gesture over a horizontal strip scrolls the page, instead of being claimed (and, on macOS, rubber-banded) by a viewport with nothing to scroll that way. It costs nothing while the tabs fit: a ScrollArea with no overflow shows no fade and takes no tab stop.
Activating a tab that is off the edge scrolls it into view, whether it was reached with the pointer or with the arrow keys and Enter — and the strip arrives already scrolled to the active tab, with no animation on that first frame. It deliberately overshoots: --forte-tabs-scroll-peek parks the tab 3rem in from the edge rather than flush against it, which leaves the neighbouring tab half painted and half fading out under the mask. Land the tab on the fade's inner edge instead and the gap beyond it is empty — the strip reads as having ended there, which is the one thing a scrolling strip must not do.
That clearance is the tab's own scroll-margin, overriding the flat 1.5rem patterns.css puts on every focusable part for SC 2.4.11. Carrying it as a real CSS property rather than a number in the script is what makes the browser's own focus scrolling agree with ours, so arrow-keying along the strip leaves the same peek as clicking does.
Set overflow="visible" on Tabs.Root to opt out and get the plain flex row back — take it when the strip sits in a layout that already handles the overflow.
A vertical strip needs a height to scroll against. The scroller is capped at the height of Tabs.Root, so give the root a height or a max-height and a long strip scrolls beside a panel that does not; leave the root unconstrained and it grows to fit its tabs exactly as before.
Animated height
Panels rarely come out the same height, and by default the component snaps from one to the next: everything below the tabs jumps up or down the moment a tab is activated. autoHeight on Tabs.Root turns that jump into a transition — the panel area travels to the new panel's height on the same clock the indicator slides on, so the strip's marker and the content underneath read as one movement.
It is off by default, and that is not timidity: a tab set whose panels are already the same height gains nothing from it, and a page-level tab set with a long panel would animate a height nobody can see the bottom of. Turn it on where the difference is small enough to follow and large enough to read as a jump — a settings pane, a card, a summary beside a list.
The track keeps following the panel after the switch, so height that arrives late moves it too: an image that has loaded, a fetch that has resolved, a disclosure the reader opened. There is no prop for that and nothing to call — a ResizeObserver on the panel drives the same transition.
Two things change while it is on, and both follow from the panel being the thing that now decides the height:
- The panel is aligned to the start of its track rather than stretched to it, so a
Tabs.Rootyou have given aheightof your own no longer has its panel fill that height. - A growing panel is taller than the track carrying it until the transition lands, so the component clips itself for exactly those frames. Anything a panel paints outside its own box — a shadow under a flush card, a focus ring against the edge — is cut off while the height is travelling and whole again as soon as it arrives. A shrinking panel never overflows, so it is never clipped.
With icons
A tab lays out its children in a row with --forte-control-gap between them, so an icon needs no wrapper or extra class.
Disabled tab
disabled on a Tabs.Tab prevents activation. The tab stays in the strip and stays reachable: arrow keys still land on it and Home/End still count it, but Enter and Space do nothing. That is intentional — a disabled control a keyboard user cannot reach is a control they cannot discover the existence of.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves focus into the strip, landing on the active tab, and then out of the strip to the active panel. The strip is a single tab stop — the other tabs are reached with the arrow keys. A strip that is currently overflowing adds one stop before it, on the scroll container itself, so a keyboard user can page along it without activating anything. |
| ArrowRight | Horizontal orientation: moves focus to the next tab, wrapping to the first at the end. |
| ArrowLeft | Horizontal orientation: moves focus to the previous tab, wrapping to the last at the start. |
| ArrowDown | Vertical orientation: moves focus to the next tab, wrapping to the first at the end. |
| ArrowUp | Vertical orientation: moves focus to the previous tab, wrapping to the last at the start. |
| Home | Moves focus to the first tab in the strip. |
| End | Moves focus to the last tab in the strip. |
| Enter | Activates the focused tab and shows its panel. |
| Space | Activates the focused tab and shows its panel. |
Arrow keys move focus only. Activation is a separate, explicit act — activateOnFocus on Tabs.List defaults to false. Automatic activation would mount every panel the user arrows past, which is hostile when a panel fetches data on mount. Set activateOnFocus to true only when the panels are cheap and already loaded. Wrapping can be turned off with loopFocus={false}.
The parts carry their own roles and relationships: Tabs.List is a tablist, each Tabs.Tab is a tab with aria-selected and an aria-controls pointing at its panel, and each Tabs.Panel is a tabpanel labelled by its tab. Panels are focusable — Base UI gives the visible panel tabIndex={0} — so a keyboard user presses Tab once more from the strip to reach the content, and a panel containing only non-interactive text can still be scrolled and read.
Hidden panels are removed from the DOM by default. keepMounted on Tabs.Panel keeps them mounted instead — set it when a panel holds scroll position or an uncommitted form that should survive a round trip. A kept-mounted panel is given the hidden attribute and display: none, and its tab index drops to -1, so its content stays out of the tab order and out of the accessibility tree while it is not the active panel.
Under forced colors the indicator paints Highlight explicitly rather than letting its background be replaced, so a pill and its label stay a matched pair; a line active tab instead keeps a transparent background and CanvasText text, because an opaque fill would cover the 2px bar that is the cue. The rail becomes CanvasText, and disabled tabs are painted GrayText rather than relying on opacity, which is not honoured in that mode.
Motion
The indicator slides without a single frame of JavaScript. Base UI measures the active tab and publishes its geometry on the indicator element as CSS custom properties — --active-tab-left, --active-tab-top, --active-tab-width, --active-tab-height, all already in px. The stylesheet consumes them directly:
.indicator {
translate: var(--active-tab-left);
width: var(--active-tab-width);
transition-property: translate, width, height;
transition-duration: var(--forte-duration-move);
transition-timing-function: var(--forte-ease-spring-snappy);
}The measurement runs once per change (and on resize, via a ResizeObserver); the movement itself is a CSS transition, so no JavaScript runs per frame and there is no animation library in the bundle. Both variants and both orientations are the same element carrying the same transition — only the geometry differs, so switching variant cannot desynchronise the motion.
It is a transition rather than a keyframe animation on purpose. A transition can be interrupted: clicking a third tab while the indicator is still travelling to the second retargets smoothly from wherever it is, instead of snapping back and restarting.
Scrolling the active tab into view is the one movement that is not a CSS transition — a scroll position cannot be one. It runs on the browser's own scrollBy({ behavior: "smooth" }), and is switched to an instant jump by reading --forte-motion-ok off the viewport rather than by asking matchMedia, so the in-page reduced-motion toggle and a subtree data-forte-motion="reduce" turn it off along with everything else. Alignment forced by a container resize is always instant: it fires on every frame of a drag, and animating each one would leave the strip chasing the pointer.
Panels move too. Tabs.Root publishes data-activation-direction naming where the new tab sits relative to the old one, and the incoming panel starts offset by --forte-travel-md on that side and slides back to rest as it fades in.
The panel area's own height is animated only if you ask for it. With autoHeight on the root, the component measures the panel that is in flow and writes that height into the root's grid-template-rows, which is what the transition runs on. The root is the only box that survives a tab switch — the outgoing panel is unmounted unless keepMounted is set, and a transition never runs on an element's first computed style — so a height animated on the panel itself would not play at all. A track list interpolates track by track as long as only its lengths differ, and the strip's auto track is identical on both sides.
Only ever one panel is readable. Tabs.Root is a grid and every Tabs.Panel is placed in the same cell, so the outgoing panel — which Base UI keeps mounted until its exit transition ends — overlaps the incoming one instead of being laid out beneath it, and the component never grows to fit both and snaps back. The outgoing panel is also taken out of flow for those frames, so the cell is sized by the incoming panel alone. Its exit is a cut rather than a fade: a panel has no background of its own, so two panels fading past each other would leave both sets of text legible at once.
Under prefers-reduced-motion there is no separate code path, and no prefers-reduced-motion block in the tabs stylesheet at all. The tokens do the work: --forte-duration-move collapses to 1ms and --forte-travel-md to 0px, so the indicator jumps to the new tab and the panel fades in place without travelling. Durations are shortened rather than set to 0s, so the transitions still run — they are simply imperceptible.
Theming
Every property below is declared on Tabs.Root, which is also why an ancestor is the wrong place to set one: the root element's own declaration wins over the inherited value. Override them on Tabs.Root itself, through its className or an inline style. To move every tab set at once, re-point the global tokens in the Default column instead — --forte-color-*, --forte-radius-*, --forte-space-*, --forte-control-* and --forte-font-* are only read here, never re-declared, so those do inherit from :root or a theme scope.
| Property | Controls | Default |
|---|---|---|
--forte-tabs-radius | Corner radius of the panel, and of a tab in the line variant — where it applies only to the two corners away from the rail, capped by the row below. A pill tab takes --forte-tabs-indicator-radius instead, so the box carrying the focus ring traces the thumb sitting under it | var(--forte-radius-surface) |
--forte-tabs-radius-line | The cap on those two corners. A line tab's other two are square, and CSS only shrinks an oversized radius to fit the corners sharing its edge — so without a cap a full-round radius rounds one all the way across the tab's height and leaves a tombstone rather than a tab | min(var(--forte-tabs-radius), calc(var(--forte-tabs-height) / 2)) |
--forte-tabs-gap | Space between adjacent tabs in the strip | var(--forte-space-1) |
--forte-tabs-content-gap | Space between the strip and the panel | var(--forte-space-4) |
--forte-tabs-height | Height of a tab | var(--forte-control-h-md) |
--forte-tabs-padding-x | Inline padding inside a tab | var(--forte-control-px-md) |
--forte-tabs-font-size | Tab label size | var(--forte-font-size-2) |
--forte-tabs-font-weight | Tab label weight | var(--forte-font-weight-medium) |
--forte-tabs-scroll-peek | How much of the neighbouring tab stays in view when the strip scrolls to a tab off the edge. At or below the ScrollArea's fade size the tab lands flush against the fade with nothing past it, which reads as the end of the strip rather than as more tabs | var(--forte-space-8) |
--forte-tabs-list-bg | Background of the strip | transparent |
--forte-tabs-list-padding | Padding inside the strip | 0px |
--forte-tabs-list-radius | Corner radius of the strip | 0px |
--forte-tabs-rail-width | Thickness of the rail (line variant only) | 1px |
--forte-tabs-rail-color | Colour of the rail (line variant only) | var(--forte-color-border-muted) |
--forte-tabs-tab-color | Label colour of an inactive tab | var(--forte-color-foreground-muted) |
--forte-tabs-tab-color-hover | Label colour on hover | var(--forte-color-foreground) |
--forte-tabs-tab-color-active | Label colour of the active tab | var(--forte-color-primary-text) |
--forte-tabs-tab-bg-hover | Tab background on hover — transparent in both variants, since hover is carried by the label colour alone; set it to opt a fill back in | transparent |
--forte-tabs-indicator-color | Fill of the sliding indicator | var(--forte-color-primary) |
--forte-tabs-indicator-size | Thickness of the indicator bar (line variant only) | 2px |
--forte-tabs-indicator-radius | Corner radius of the indicator. A line indicator applies it only to the two corners away from the rail — its rail-side pair is square, like the tab's — while a pill indicator rounds all four | var(--forte-radius-pill) |
--forte-tabs-panel-padding | Padding inside the panel | 0px |
--forte-tabs-auto-height-duration | How long the panel area takes to grow or shrink to the active panel, under autoHeight on the root | var(--forte-duration-move) |
variant="pill" is itself implemented by overriding five of these on the root — --forte-tabs-list-bg, --forte-tabs-list-padding, --forte-tabs-list-radius, --forte-tabs-indicator-color and --forte-tabs-indicator-radius. Setting them yourself on a pill tab set still works: the library's rules live in the forte.components cascade layer, and an unlayered rule of yours beats a layered one whatever its specificity.
The parts also expose their state as data attributes — data-variant, data-overflow, data-orientation, data-active, data-disabled, data-activation-direction, and data-auto-height with data-resizing on the root while a height transition is in flight — so a Tailwind arbitrary variant such as data-[variant=pill]:... can target them without a wrapper element.
API reference
Each part forwards every prop it does not consume to its Base UI counterpart, so the tables below list what forte-ui adds or changes the default of. Tabs.Root also takes value, defaultValue, onValueChange and orientation; Tabs.Tab takes value (required) and disabled; Tabs.Panel takes value (required). All five parts take render for changing the underlying element.
Tabs.Root
| Prop | Type | Default | Description |
|---|---|---|---|
autoHeight | boolean | false | Whether the component animates between panel heights. Off by default: the panel area snaps to whatever the new panel needs, which is what a tab set has always done and what a page of equal-height panels should keep doing. Turn it on when the panels differ in height enough for the switch to look like a jump. The track is sized from the active panel and transitioned, and it keeps following that panel afterwards — content that arrives late, an image that has loaded or a disclosure the reader opened, moves the height with the same transition rather than snapping. Two things change while it is on. The panel is aligned to the start of its track rather than stretched, so it no longer fills a `Tabs.Root` that has been given a height of its own; and the component clips itself for the length of a *growing* transition, so anything a panel deliberately paints outside its own box is cut off for those frames. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
overflow | TabsOverflow | scroll | What a strip with more tabs than room does. `"scroll"` puts the tabs in a `ScrollArea` that only engages once they stop fitting — the overflowing end fades out, a swipe or a drag brings it back, and activating a tab scrolls it into view. `"visible"` restores the plain flex row, which spills out of its container instead; take it when the strip sits in a layout that already handles the overflow. A vertical strip needs a height to scroll against: the scroller is capped at the height of `Tabs.Root`, so give the root one (or a `max-height`) and the strip scrolls beside a panel that does not. |
variant | TabsVariant | line | Which indicator treatment the tab strip uses. `"line"` draws a rail with a sliding underline; `"pill"` draws a filled pill that slides behind the active tab. Both are the same indicator element moved by the same CSS variables — only the geometry differs. |
Tabs.List
| Prop | Type | Default | Description |
|---|---|---|---|
activateOnFocus | boolean | false | Whether arrow-key focus also activates the tab it lands on. Left `false` so arrow keys only move focus and activation happens on Enter or Space — automatic activation loads every panel the user arrows past, which is hostile when a panel fetches data. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
loopFocus | boolean | true | Whether arrow-key focus wraps from the last tab back to the first. |
Tabs.Tab
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Tabs.Indicator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
renderBeforeHydration | boolean | true | Whether to emit a tiny inline script that positions the indicator before React hydrates. Defaults to `true` here rather than Base UI's `false`: without it a server-rendered tab strip shows no indicator at all until hydration finishes, which reads as a broken selection. |
Tabs.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
keepMounted | boolean | false | Whether the panel stays in the DOM while hidden. Required if you want the exit transition to play, or if the panel holds scroll position or uncommitted form state that should survive a round trip. |