Skip to Content

Tabs

A tabbed navigation component built on Base UI’s Tabs primitive. Features an animated indicator that slides between active tabs.

Import

import { Tabs } from "chunks-ui";

Basic Usage

<Tabs.Root defaultValue="tab-1"> <Tabs.List> <Tabs.Tab value="tab-1">Account</Tabs.Tab> <Tabs.Tab value="tab-2">Security</Tabs.Tab> <Tabs.Tab value="tab-3">Notifications</Tabs.Tab> <Tabs.Indicator /> </Tabs.List> <Tabs.Contents> <Tabs.Content value="tab-1"> <p className="p-4">Account settings content.</p> </Tabs.Content> <Tabs.Content value="tab-2"> <p className="p-4">Security settings content.</p> </Tabs.Content> <Tabs.Content value="tab-3"> <p className="p-4">Notification preferences.</p> </Tabs.Content> </Tabs.Contents> </Tabs.Root>

Account settings content.

Security settings content.

Notification preferences.

Tabs.Contents keeps every Tabs.Content mounted. With Motion it slides between them and animates the height. For plain panels without animation, use Tabs.Panel instead, as in the Controlled example below. Only the active Tabs.Panel is mounted, unless you set keepMounted.

Each Tabs.Content is a Base UI Tabs.Panel that stays mounted. It has role="tabpanel", its tab names it, and the tab’s aria-controls points to it. The active panel is in the tab order, as Tabs.Panel is, and shows a focus ring inside its edge. Outside Tabs.Contents, every Tabs.Content shows, and the inactive ones are inert; for plain panels that show and hide, use Tabs.Panel.

With Motion, Tabs.Contents clips the panels beside the active one. The clip leaves 4px of room around the content (overflow-clip-margin), so a focus ring of up to 4px outside an element at the edge shows in full. That room counts as overflow too: when Tabs.Contents sits flush against the edge of a scroll container, the container can scroll 4px further, which can show a scrollbar or let a touch screen pan sideways. Give that scroll container at least 4px of padding, or give the content an inset focus ring. Safari does not support overflow-clip-margin, so it adds no overflow there, but it clips a ring at the edge; the same padding or inset ring helps. Without Motion, or with reduced motion, nothing is clipped.

Controlled

function ControlledTabs() { const [tab, setTab] = useState("overview"); return ( <Tabs.Root value={tab} onValueChange={setTab}> <Tabs.List> <Tabs.Tab value="overview">Overview</Tabs.Tab> <Tabs.Tab value="details">Details</Tabs.Tab> <Tabs.Indicator /> </Tabs.List> <Tabs.Panel value="overview">Overview content</Tabs.Panel> <Tabs.Panel value="details">Details content</Tabs.Panel> </Tabs.Root> ); }

Vertical Orientation

Set orientation="vertical" on the root. The list stacks its tabs in a column (flex-col), and the arrow keys move up and down:

<Tabs.Root defaultValue="tab-1" orientation="vertical"> <Tabs.List> <Tabs.Tab value="tab-1">General</Tabs.Tab> <Tabs.Tab value="tab-2">Advanced</Tabs.Tab> <Tabs.Indicator /> </Tabs.List> <Tabs.Contents> <Tabs.Content value="tab-1"> <p className="p-4">General settings</p> </Tabs.Content> <Tabs.Content value="tab-2"> <p className="p-4">Advanced settings</p> </Tabs.Content> </Tabs.Contents> </Tabs.Root>

General settings

Advanced settings

Animated Indicator

The Tabs.Indicator component renders a pill behind the active tab. With Motion installed, a spring moves it between tabs. When the tabs are hidden (for example with display: none) and shown again, the pill appears over the active tab at once.

Without Motion, or with reduced motion, it takes its position and size from the CSS variables Base UI sets on it (--active-tab-left, --active-tab-top, --active-tab-width, --active-tab-height). It slides with a CSS transition, which reduced motion turns off.

Only a change of tab slides the pill. When the active tab or the list changes size (for example a font size, padding or container width change), the pill moves to the tab at once, so it does not trail behind it. A size change during a slide changes where the slide ends.

Server rendering

The pill is in the server-rendered page before React hydrates. Tabs.Indicator sets Base UI’s renderBeforeHydration by default: Base UI renders a small inline <script> after the indicator that measures the active tab and shows the pill over it. Set renderBeforeHydration={false} to leave the script out. The pill then shows once React has hydrated.

A CSP that doesn’t allow inline scripts (no 'unsafe-inline' in script-src, or default-src when script-src is absent, or a nonce, hash or 'strict-dynamic', which make browsers ignore 'unsafe-inline') blocks the script. Pass your per-request nonce to Base UI’s CSPProvider, so the script carries it:

import { CSPProvider } from "@base-ui/react/csp-provider"; <CSPProvider nonce={nonce}> <App /> </CSPProvider>

Or opt out with renderBeforeHydration={false}.

Restyling the indicator

The indicator box always covers the active tab. While Motion drives it, Motion writes left, top, width and height as inline styles, and they win over classes. Position and size classes on Tabs.Indicator, such as top-auto bottom-0 h-0.5, therefore have no effect with Motion. The CSS fallback applies them, so the indicator would look different with and without Motion.

To change the look, keep the box and change what is drawn in it. For an underline in a horizontal list, remove the pill’s background and shadow, and draw the line with a pseudo-element at the bottom of the box:

<Tabs.Root defaultValue="tab-1"> <Tabs.List className="rounded-none border-border border-b bg-transparent p-0"> <Tabs.Tab value="tab-1">General</Tabs.Tab> <Tabs.Tab value="tab-2">Advanced settings</Tabs.Tab> <Tabs.Indicator className="rounded-none bg-transparent shadow-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-primary" /> </Tabs.List> </Tabs.Root>

The line then sits along the bottom of the active tab and moves with it, with Motion and with the CSS fallback.

Disabled tabs

Set disabled on a Tabs.Tab. The tab dims to 50%, and a user can’t select it. Base UI keeps it focusable, so it gets aria-disabled, not the disabled attribute. The arrow keys move focus onto it, and Enter or Space does nothing. With activateOnFocus, focus does not select it either.

<Tabs.Root defaultValue="tab-1"> <Tabs.List> <Tabs.Tab value="tab-1">Account</Tabs.Tab> <Tabs.Tab value="tab-2" disabled> Billing </Tabs.Tab> <Tabs.Tab value="tab-3">Notifications</Tabs.Tab> <Tabs.Indicator /> </Tabs.List> <Tabs.Contents> <Tabs.Content value="tab-1"> <p className="p-4">Account settings content.</p> </Tabs.Content> <Tabs.Content value="tab-2"> <p className="p-4">Billing details.</p> </Tabs.Content> <Tabs.Content value="tab-3"> <p className="p-4">Notification preferences.</p> </Tabs.Content> </Tabs.Contents> </Tabs.Root>

Account settings content.

Billing details.

Notification preferences.

At mount, a value or an explicit defaultValue can select a disabled tab. The tab stays selected, and the indicator under it dims to 50%. Without either, the root selects the first enabled tab.

When the selected tab becomes disabled later, a controlled root keeps it selected. The indicator dims and fades with the tab (at once with reduced motion). Base UI does not call onValueChange, so moving the selection is up to you. An uncontrolled root moves the selection to the first enabled tab, also when defaultValue selected it, and calls onValueChange with the new value and the reason "disabled". eventDetails.cancel() can’t stop this move.

The indicator does not follow a disabled opacity of your own on Tabs.Tab: it stays at 50%. Set that opacity with aria-disabled: (for example aria-disabled:opacity-70), as a disabled tab is never :disabled. To dim the indicator the same, give Tabs.Indicator that opacity with the variant the library dims it with: group-has-[[data-active][aria-disabled=true]]/tabs-list:opacity-70. It replaces the library’s opacity-50.

Routed Tabs (single panel)

Tabs.Contents mounts every panel in a flex track and slides between them on the x-axis. That works great for tabbed UIs where every panel coexists — but it doesn’t fit a router-driven setup, where only one panel is rendered at a time (typically through a single outlet) and the previous panel has already unmounted by the time the new one appears.

For that case, opt out of Tabs.Contents and reach for one of:

  • Tabs.Animate — drop-in wrapper around the active panel content. Re-keys on tab change so children always remount, and accepts initial / animate / transition props so you bring your own animation.
  • useTabsValue() — hook returning the active tab value. Use it when you need a motion primitive Tabs.Animate doesn’t expose (AnimatePresence, layout animations, custom orchestration).

The two compose: read useTabsValue() to derive a value (e.g. direction relative to a known order), then feed it to Tabs.Animate.

With Tabs.Animate (component)

The simplest case. One outlet, one motion target. Pass initial / animate / transition directly:

<Tabs.Root value={tab} onValueChange={setTab}> <Tabs.List> <Tabs.Tab value="home">Home</Tabs.Tab> <Tabs.Tab value="profile">Profile</Tabs.Tab> <Tabs.Tab value="settings">Settings</Tabs.Tab> <Tabs.Indicator /> </Tabs.List> <Tabs.Animate initial={{ y: 16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.2 }} > <p className="p-4">Content for the {tab} tab</p> </Tabs.Animate> </Tabs.Root>

Welcome — your dashboard at a glance.

Tabs.Animate can also wrap several Tabs.Panels. Base UI mounts only the active panel, so that panel animates in on every tab change:

<Tabs.Animate> <Tabs.Panel value="home">Home</Tabs.Panel> <Tabs.Panel value="profile">Profile</Tabs.Panel> <Tabs.Panel value="settings">Settings</Tabs.Panel> </Tabs.Animate>

Tabs.Animate remounts everything inside it when the tab changes. A keepMounted panel inside it therefore still remounts and loses its state. To keep panel state, put keepMounted panels outside Tabs.Animate, or use Tabs.Contents.

With useTabsValue (hook)

When you need a motion feature that Tabs.Animate doesn’t surface — here, an exit animation via AnimatePresence:

import { useTabsValue } from "chunks-ui"; import { AnimatePresence, motion } from "motion/react"; function HookPanel({ children }: { children: React.ReactNode }) { const value = useTabsValue(); return ( <AnimatePresence mode="wait" initial={false}> <motion.div key={String(value)} initial={{ scale: 0.97, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.97, opacity: 0 }} transition={{ duration: 0.18 }} > {children} </motion.div> </AnimatePresence> ); }

Welcome — your dashboard at a glance.

Direction-aware slide (hook + component)

The case Tabs.Contents cannot serve: a routed-tabs UI where you’ve opted out of the multi-panel slide because only one panel is mounted at a time, but you still want the new panel to enter from the direction the user navigated — right when going forward, left when going back.

The hook and the component compose cleanly here. useTabsValue() exposes the active value; a small wrapper hook stores the previous value in a ref, compares its position in a known order, and derives a direction. That direction feeds initial.x on Tabs.Animate, which handles re-keying and motion-availability fallbacks for you:

import { Tabs, useTabsValue } from "chunks-ui"; import { useEffect, useRef } from "react"; function useTabsDirection(order: readonly string[]): 1 | -1 { const value = String(useTabsValue() ?? ""); const prevRef = useRef(value); const currentIdx = order.indexOf(value); const prevIdx = order.indexOf(prevRef.current); const direction: 1 | -1 = currentIdx === -1 || prevIdx === -1 || currentIdx >= prevIdx ? 1 : -1; useEffect(() => { prevRef.current = value; }, [value]); return direction; } function DirectionAwarePanel({ order, children, }: { order: readonly string[]; children: React.ReactNode; }) { const direction = useTabsDirection(order); return ( <Tabs.Animate initial={{ x: direction * 32, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ type: "spring", stiffness: 320, damping: 30 }} > {children} </Tabs.Animate> ); }

Welcome — your dashboard at a glance.

Don’t wrap Tabs.Animate inside Tabs.Contents. Tabs.Contents already mounts every panel and slides between them; layering Tabs.Animate on top double-animates and re-mounts panels that haven’t changed visibility. Tabs.Animate is for the routed/single-outlet case; Tabs.Contents is for the multi-panel case. Pick one.

Compound Parts

PartDescription
Tabs.RootState container. Manages active tab value.
Tabs.ListHorizontal (or vertical) container for tab triggers. Renders a rounded, muted background behind the tabs.
Tabs.TabIndividual tab trigger button. Shows muted text by default, foreground text when active.
Tabs.PanelContent panel associated with a tab value. Mounted only while its tab is active, unless keepMounted is set. No animation.
Tabs.IndicatorPill behind the active tab. Slides to a tab when it becomes active, and follows a resize at once. Shows before hydration on a server-rendered page.
Tabs.ContentsContainer that keeps every Tabs.Content mounted. With Motion it slides between them and animates the height. Without Motion, or with reduced motion, it shows only the active one, with no animation.
Tabs.ContentIndividual content panel for use inside Tabs.Contents. Requires a value matching a Tabs.Tab. A Base UI Tabs.Panel that stays mounted, labelled by its tab.
Tabs.AnimateWrapper for routed tabs or for several Tabs.Panels. Re-keys its children on tab change (so they remount) and accepts initial / animate / transition.

Hooks

HookDescription
useTabsValue()Read the active tab value from the surrounding <Tabs.Root> context. Throws if used outside a Tabs.Root. Use as an escape hatch when Tabs.Animate doesn’t fit.

Props

Tabs.Root, Tabs.List, Tabs.Tab, Tabs.Panel, and Tabs.Indicator are thin style wrappers. Props are forwarded to the underlying Base UI Tabs. One default differs: Tabs.Indicator has renderBeforeHydration on (see Server rendering).

Tabs.Contents Props

NameTypeDefault
childrenReactNode

The <Tabs.Content> elements to animate between.

transitionMotionOptions

Custom spring transition config passed to Motion.

springs.content

All other props are forwarded to the underlying <div> element.

Tabs.Content Props

NameTypeDefault
valueany

The value of the TabPanel. It will be shown when the Tab with the corresponding value is active.

All other props are forwarded to the underlying Base UI Tabs.Panel, except keepMounted and hidden: Tabs.Contents keeps every panel mounted and decides which one shows.

Tabs.Animate Props

NameTypeDefault
animateMotionTarget

Motion animate state.

{ y: 0, opacity: 1 }
childrenReactNode
initialMotionTarget

Motion initial state.

{ y: 8, opacity: 0 }
transitionMotionOptions

Motion transition config.

springs.content

All other props are forwarded to the underlying <div> element.

Last updated on