Skip to Content

Tabs

A headless, accessible tabs primitive. Renders semantic role="tablist" / "tab" / "tabpanel" markup with the full WAI-ARIA tabs pattern wired up; aria-selected, aria-controls, aria-labelledby, roving tabIndex, and orientation-aware arrow-key navigation. The optional Tabs.Indicator rides on motion’s shared layoutId, so the active marker slides smoothly between triggers when selection changes.

Account Settings

Manage your account information and preferences.

import { Tabs } from "@togetheragency/ui/tabs"; const items = [ { id: "account", label: "Account", body: "Manage your account." }, { id: "security", label: "Security", body: "2FA and password." }, { id: "notifications", label: "Notifications", body: "How we reach you." }, { id: "billing", label: "Billing", body: "Subscriptions & payment." }, ]; export function BasicTabs() { return ( <Tabs.Root defaultValue="account"> <Tabs.Container> <Tabs.List aria-label="Settings sections" className="rounded-full bg-neutral-100 p-1" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="flex-1"> <Tabs.Separator /> {tab.label} <Tabs.Indicator className="rounded-full bg-white shadow-sm ring-1 ring-black/5" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> {items.map((tab) => ( <Tabs.Content key={tab.id} id={tab.id} className="px-1 py-4"> <h3 className="mb-1 font-semibold">{tab.label}</h3> <p className="text-sm text-neutral-500">{tab.body}</p> </Tabs.Content> ))} </Tabs.Root> ); }

Import

import { Tabs } from "@togetheragency/ui/tabs";

The component is exported as a compound object; every sub-component is a property of Tabs:

Tabs.Root; Tabs.Container; Tabs.List; Tabs.Trigger; Tabs.Indicator; Tabs.Progress; Tabs.Separator; Tabs.Content;

Composition

Tabs follows the WAI-ARIA pattern strictly. The minimum viable tabs widget nests four parts — Root → Container → List → Trigger, with Content siblings of Container:

<Tabs.Root defaultValue="one"> <Tabs.Container> <Tabs.List aria-label="Sections"> <Tabs.Trigger id="one"> One <Tabs.Indicator /> </Tabs.Trigger> <Tabs.Trigger id="two"> Two <Tabs.Indicator /> </Tabs.Trigger> </Tabs.List> </Tabs.Container> <Tabs.Content id="one">Panel one</Tabs.Content> <Tabs.Content id="two">Panel two</Tabs.Content> </Tabs.Root>
  • Tabs.Root renders a <div> and owns selection. Pass defaultValue or value + onValueChange to choose between uncontrolled and controlled.
  • Tabs.Container is a position: relative wrapper around Tabs.List. It provides the positioning context the absolutely-rendered Tabs.Indicator needs — required whenever you use an indicator.
  • Tabs.List is the role="tablist" group, with aria-orientation derived from the root.
  • Tabs.Trigger is the real role="tab" <button>. Each one carries an id that ties it to a Tabs.Content with the same id.
  • Tabs.Indicator is an optional sliding marker. Rendered only inside the currently selected trigger; motion’s shared layoutId animates the marker between triggers when selection moves.
  • Tabs.Progress is an optional CSS-driven progress indicator. Renders only when Tabs.Root has autoplay enabled and only inside the currently selected trigger. Its animation duration tracks autoplayDelay via the --tabs-autoplay-duration custom property — no React state per frame.
  • Tabs.Separator is an optional decorative divider between adjacent triggers. Fades out on the active tab so the indicator can take its place.
  • Tabs.Content is the role="tabpanel" for an id. By default, inactive panels are unmounted — pass forceMount to keep them in the DOM.

Tabs.Indicator uses motion’s shared layoutId (tabs-indicator-<scope>) to animate the marker between triggers. Selection state lives in a ref + per-key listener map inside Tabs.Root, so changing tabs only re-renders the affected trigger and content — never sibling tabs, never the root.

Usage

Basic usage

Uncontrolled mode — pass defaultValue and let the component own selection.

<Tabs.Root defaultValue="one"> <Tabs.Container> <Tabs.List aria-label="Sections"> <Tabs.Trigger id="one"> One <Tabs.Indicator /> </Tabs.Trigger> <Tabs.Trigger id="two"> Two <Tabs.Indicator /> </Tabs.Trigger> </Tabs.List> </Tabs.Container> <Tabs.Content id="one">…</Tabs.Content> <Tabs.Content id="two">…</Tabs.Content> </Tabs.Root>

Vertical orientation

Pass orientation="vertical" to lay the list out as a column beside the panel. Arrow keys swap from Left/Right to Up/Down automatically.

Account Settings

Manage your account information and preferences.

<Tabs.Root orientation="vertical" defaultValue="account"> <Tabs.Container> <Tabs.List aria-label="Vertical tabs" className="w-44 gap-1 p-1"> {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="h-9 w-full justify-start px-3" > {tab.label} <Tabs.Indicator className="rounded-lg bg-white shadow-sm ring-1 ring-black/5" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> {items.map((tab) => ( <Tabs.Content key={tab.id} id={tab.id} className="flex-1 px-4 py-2"> {tab.body} </Tabs.Content> ))} </Tabs.Root>

Controlled

Pass value + onValueChange to lift selection out of the component. The value type is always a string matching a trigger id.

const [value, setValue] = useState("account"); <Tabs.Root value={value} onValueChange={setValue}> </Tabs.Root>;

Manual focus activation

By default, focusing a trigger via arrow keys also activates it (activationMode="automatic"). Set activationMode="manual" to require Space/Enter to activate — useful when each panel is expensive to render.

Account Settings

Manage your account information and preferences.

<Tabs.Root defaultValue="account" activationMode="manual"> </Tabs.Root>

Disabled triggers

Pass disabled on Tabs.Root to disable every trigger, or on an individual Tabs.Trigger to disable just one. Disabled triggers are skipped during arrow / Home / End navigation.

Account Settings

Manage your account information and preferences.

<Tabs.Root defaultValue="account"> <Tabs.Container> <Tabs.List aria-label="With a disabled tab"> {items.map((tab, i) => ( <Tabs.Trigger key={tab.id} id={tab.id} disabled={i === 2}> <Tabs.Separator /> {tab.label} <Tabs.Indicator /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> </Tabs.Root>

Autoplay

Pass autoplay on Tabs.Root to advance the selected tab on a timer. Tune the interval with autoplayDelay (milliseconds, default 2500). By default autoplay wraps from the last tab back to the first — set loop={false} to stop on the last tab instead. Any manual selection (click, keyboard, or a controlled value change) resets the timer from the newly selected tab. Disabled triggers are skipped automatically.

Account Settings

Manage your account information and preferences.

<Tabs.Root defaultValue="account" autoplay autoplayDelay={2500}> <Tabs.Container> <Tabs.List aria-label="Autoplay tabs" className="rounded-full bg-neutral-100 p-1" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="flex-1"> <Tabs.Separator /> {tab.label} <Tabs.Indicator className="rounded-full bg-white shadow-sm ring-1 ring-black/5" /> <Tabs.Progress className="inset-x-2 bottom-1 h-0.5 rounded-full bg-neutral-900/60" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> </Tabs.Root>

Tabs.Progress is the visual companion to autoplay. It only renders for the active trigger, so when selection moves the previous element unmounts and a fresh one mounts on the new trigger — the CSS animation restarts from zero automatically, no key or refs needed. The animation duration reads from the --tabs-autoplay-duration custom property that Tabs.Root emits, so Tabs.Progress and the autoplay timer stay in lockstep without React managing per-frame state.

Tabs.Progress respects prefers-reduced-motion: the fill animation is disabled and the bar renders fully filled. Tab advancement still happens on the autoplay timer.

Persisting panel state

Inactive panels are unmounted by default. Pass forceMount on Tabs.Content to keep them in the DOM — handy for preserving form state, scroll position, or expensive sub-trees between tab switches.

<Tabs.Content id="settings" forceMount> <SettingsForm /> </Tabs.Content>

When forceMount is set, the panel is hidden via the hidden attribute (data-state="inactive") instead of being removed from the tree.

Examples

Segmented pill

The default style — pill-shaped indicator slides between triggers with motion’s shared layoutId. Separators fade out on the active tab so the indicator owns its slot cleanly.

Account Settings

Manage your account information and preferences.

<Tabs.Root defaultValue="account"> <Tabs.Container> <Tabs.List aria-label="Settings sections" className="rounded-full bg-neutral-100 p-1" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="flex-1"> <Tabs.Separator /> {tab.label} <Tabs.Indicator className="rounded-full bg-white shadow-sm ring-1 ring-black/5" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> </Tabs.Root>

Underline

A different look using the same primitives — override the indicator’s default rounded-pill styling with a thin bar pinned to the bottom edge. Same sliding animation, very different feel.

Account Settings

Manage your account information and preferences.

<Tabs.Root defaultValue="account"> <Tabs.Container> <Tabs.List aria-label="Underline tabs" className="gap-2 border-b bg-transparent p-0" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="h-10 rounded-none px-3 text-neutral-500 data-[state=active]:text-neutral-900" > {tab.label} <Tabs.Indicator className="inset-x-0! top-auto! -bottom-px! z-10! h-0.5 rounded-none bg-neutral-900 shadow-none ring-0" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> </Tabs.Root>

Autoplay underline

Combine autoplay with an underline-style indicator: the dim base bar marks where the active tab sits, and Tabs.Progress rides on top as a filled overlay that drains in sync with the autoplay timer. Because both elements only mount under the active trigger, they restart together when selection changes.

Account Settings

Manage your account information and preferences.

<Tabs.Root defaultValue="account" autoplay autoplayDelay={3000}> <Tabs.Container> <Tabs.List aria-label="Autoplay underline tabs" className="gap-2 border-b bg-transparent p-0" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="h-10 rounded-none px-3 text-neutral-500 data-[state=active]:text-neutral-900" > {tab.label} <Tabs.Indicator className="inset-x-0! top-auto! -bottom-px! z-10! h-0.5 rounded-none bg-neutral-900/30 shadow-none ring-0" /> <Tabs.Progress className="inset-x-0 top-auto -bottom-px z-20 h-0.5 rounded-none bg-neutral-900" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> </Tabs.Root>

Vertical settings layout

orientation="vertical" paired with a fixed-width list — classic side-nav-with-content pattern.

Account Settings

Manage your account information and preferences.

<Tabs.Root orientation="vertical" defaultValue="account" className="rounded-2xl border p-2"> <Tabs.Container> <Tabs.List aria-label="Vertical tabs" className="w-44 gap-1 p-1"> {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="h-9 w-full justify-start px-3" > {tab.label} <Tabs.Indicator className="rounded-lg bg-white shadow-sm ring-1 ring-black/5" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> {items.map((tab) => ( <Tabs.Content key={tab.id} id={tab.id} className="flex-1 px-4 py-2"> <h3 className="mb-1 font-semibold">{tab.label}</h3> <p className="text-sm text-neutral-500">{tab.body}</p> </Tabs.Content> ))} </Tabs.Root>

Vertical autoplay underline

The vertical sibling of the autoplay underline pattern: a thin rail runs down the side of the list, Tabs.Indicator marks the active tab as a dim segment of that rail, and Tabs.Progress fills top-to-bottom on top of it. The default Tabs.Progress animation switches from horizontal scaleX to vertical scaleY automatically when nested inside a vertical Tabs.List, so the same primitives work without any extra wiring.

Account Settings

Manage your account information and preferences.

<Tabs.Root orientation="vertical" defaultValue="account" autoplay autoplayDelay={3000} > <Tabs.Container> <Tabs.List aria-label="Vertical autoplay underline tabs" className="w-44 gap-1 border-l bg-transparent p-0" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="h-10 w-full justify-start rounded-none px-4 text-neutral-500 data-[state=active]:text-neutral-900" > {tab.label} <Tabs.Indicator className="inset-y-0! right-auto! -left-px! z-10! h-full w-0.5 rounded-none bg-neutral-900/30 shadow-none ring-0" /> <Tabs.Progress className="inset-y-0 right-auto -left-px z-20 h-full w-0.5 rounded-none bg-neutral-900" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> </Tabs.Root>

Animated content transitions

Tabs.Indicator is a motion.span whose transition prop is forwarded to motion (the component only locks down layoutId and children), so any spring or tween config works. The panel body can be animated independently by wrapping a motion.div in AnimatePresence inside Tabs.Content and keying it on the active id — mode="wait" plays the old panel’s exit before the new panel’s initial → animate runs.

Two important wiring details that make the exit actually play:

  • Use controlled mode so you can read the active id, and render exactly one Tabs.Content whose id follows it.
  • Pass forceMount on that Tabs.Content. Without it, the moment the active id changes, Tabs.Content returns null for the deselecting panel — that tears down the AnimatePresence inside before it can run exit, and you get a snap instead of a transition. forceMount keeps the panel element mounted across the change so AnimatePresence stays alive.
  • forceMount also adds a brief hidden attribute on the same-tick render before selection state settles. Override its display: none with a tiny CSS rule ([&[hidden]]:block! here) so the exit animation can actually paint.

Account Settings

Manage your account information and preferences.

import { Tabs } from "@togetheragency/ui/tabs"; import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; export function AnimatedContentTabs() { const [value, setValue] = useState("account"); const active = items.find((tab) => tab.id === value) ?? items[0]; return ( <Tabs.Root value={value} onValueChange={setValue}> <Tabs.Container> <Tabs.List aria-label="Animated content tabs" className="rounded-full bg-neutral-100 p-1" > {items.map((tab) => ( <Tabs.Trigger key={tab.id} id={tab.id} className="flex-1"> <Tabs.Separator /> {tab.label} {/* Custom motion transition for the sliding indicator */} <Tabs.Indicator transition={{ type: "spring", stiffness: 520, damping: 38 }} className="rounded-full bg-white shadow-sm ring-1 ring-black/5" /> </Tabs.Trigger> ))} </Tabs.List> </Tabs.Container> <Tabs.Content id={active.id} forceMount className="relative overflow-hidden px-1 py-4 [[hidden]]:block!" > <AnimatePresence initial={false} mode="wait"> <motion.div key={active.id} initial={{ opacity: 0, filter: "blur(8px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(8px)" }} transition={{ duration: 0.25, ease: "easeOut" }} > <h3 className="mb-1 font-semibold">{active.title}</h3> <p className="text-sm text-neutral-500">{active.body}</p> </motion.div> </AnimatePresence> </Tabs.Content> </Tabs.Root> ); }

Notes on this pattern:

  • The Tabs.Indicator transition prop replaces the default spring entirely. When the user prefers reduced motion the component still forces { duration: 0 } for accessibility — your transition is only used when motion is allowed.
  • Rendering a single Tabs.Content whose id tracks the active value keeps the WAI-ARIA wiring intact (aria-controls / aria-labelledby still match) while letting AnimatePresence see exactly one keyed motion child at a time. mode="wait" ensures the outgoing panel finishes its exit before the new one mounts; drop it for crossfade-style overlap instead.
  • The blur+opacity transition has no translate, so there’s no layout jump during the change. Switch to mode="popLayout" (drop mode="wait") if you want the old and new panels to crossfade simultaneously instead of sequentially.

API Reference

Tabs.Root

Renders a <div> and owns selection. Extends all native div props.

PropTypeDefaultDescription
valuestringundefinedControlled selected tab id. Pair with onValueChange.
defaultValuestringundefinedInitial selected tab id in uncontrolled mode.
onValueChange(value: string) => voidundefinedFires whenever selection changes.
orientation"horizontal" | "vertical""horizontal"Tab list orientation. Drives arrow-key direction and the aria-orientation attribute on Tabs.List.
disabledbooleanfalseDisables every trigger.
activationMode"automatic" | "manual""automatic"When automatic, focusing a trigger via arrow keys also activates it. When manual, requires Space/Enter.
autoplaybooleanfalseAutomatically advance the selected tab on a timer. Any manual selection (click, keyboard, or controlled value update) re-arms the timer from the new tab.
autoplayDelaynumber2500Time in milliseconds between automatic advances. Also exposed to CSS via --tabs-autoplay-duration so Tabs.Progress (or custom CSS) stays in lockstep.
loopbooleantrueWhen autoplay reaches the last tab, wrap to the first. Ignored when autoplay is false.
classNamestringMerged via cn. Defaults set flex gap-2 with column/row direction matching the orientation.
...restReact.ComponentPropsWithoutRef<"div">Forwarded to the underlying <div>.

Data attributes: data-slot="tabs-root", data-orientation="horizontal" | "vertical", data-autoplay (empty string when autoplay is enabled). When autoplay is enabled, Tabs.Root also sets the --tabs-autoplay-duration CSS custom property on its element (in ms), which Tabs.Progress and any custom CSS can read to stay in sync with the timer.

Tabs.Container

Positioning context for the list and the absolutely-rendered indicator. Required whenever you use Tabs.Indicator.

PropTypeDefaultDescription
classNamestringMerged via cn. Defaults to relative so the indicator can be positioned.
...restReact.ComponentPropsWithoutRef<"div">Forwarded to the underlying <div>.

Data attributes: data-slot="tabs-list-container".

Tabs.List

The role="tablist" group of triggers. Renders a <div> with aria-orientation derived from the root.

PropTypeDefaultDescription
classNamestringMerged via cn. Defaults set inline-flex p-1 plus a row/column flow based on orientation.
...restReact.ComponentPropsWithoutRef<"div">Forwarded to the underlying <div>.

Built-in attributes: role="tablist", aria-orientation, data-slot="tabs-list", data-orientation.

Always pass aria-label (or aria-labelledby) to Tabs.List so the tablist has an accessible name.

Tabs.Trigger

The interactive role="tab" <button>. Wired with aria-selected, aria-controls, a roving tabIndex, and orientation-aware arrow-key navigation.

PropTypeDefaultDescription
idstringrequiredLogical id of the tab. Pairs with the matching Tabs.Content and identifies the trigger to controlled selection and keyboard navigation.
type"button" | "submit" | "reset""button"Standard button type.
disabledbooleanfalseDisables just this trigger. Overrides root disabled.
onClickReact.MouseEventHandler<HTMLButtonElement>undefinedFires before selection. Call event.preventDefault() to skip the built-in selection change.
onKeyDownReact.KeyboardEventHandler<HTMLButtonElement>undefinedFires before the built-in arrow-key handling. Call event.preventDefault() to skip navigation.
onFocusReact.FocusEventHandler<HTMLButtonElement>undefinedFires before the built-in automatic activation. Call event.preventDefault() to skip activation on focus.
classNamestringMerged via cn — defaults include the pill styling, cursor, focus ring, and data-[state=active] color hooks you can override.
...restOmit<React.ComponentPropsWithoutRef<"button">, "id">Forwarded to the underlying <button>.

Built-in attributes: role="tab", aria-selected, aria-controls, tabIndex (0 when selected, -1 otherwise), data-slot="tabs-trigger", data-state="active" | "inactive", data-disabled (empty string when disabled).

Tabs.Indicator

A sliding marker rendered inside the currently selected trigger. Uses motion’s shared layoutId to animate position and size from the previous trigger to the new one when selection changes. Only the active trigger renders an indicator.

PropTypeDefaultDescription
transitionMotionProps["transition"]{ type: "spring", stiffness: 380, damping: 32 }Motion transition. Automatically replaced with { duration: 0 } when the user prefers reduced motion.
classNamestringMerged via cn — defaults include -z-[1] absolute inset-0 rounded-3xl bg-white shadow-sm ring-1 ring-black/5. Override freely to change the look (underline, fill, gradient, etc.).
...restOmit<HTMLMotionProps<"span">, "layoutId" | "children">Forwarded to the underlying motion.span (sans layoutId and children, which the component owns).

Data attributes: data-slot="tabs-indicator". The shared layoutId is scoped per Tabs.Root instance (tabs-indicator-<scope>), so multiple tabs on the same page never collide.

Tabs.Progress

A CSS-only autoplay progress indicator rendered inside the currently selected trigger. Returns null unless Tabs.Root has autoplay enabled and this is the active trigger. The fill animation runs from scaleX(0) to scaleX(1) for var(--tabs-autoplay-duration, 2500ms) and restarts naturally on selection change — the element unmounts on the old trigger and remounts on the new one, with no React state per frame.

PropTypeDefaultDescription
classNamestringMerged via cn — defaults to pointer-events-none absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-neutral-900/40. Override to change position, thickness, color, or shape (e.g. a top bar, a side rail, or a ring around the indicator).
...restReact.ComponentPropsWithoutRef<"span">Forwarded to the underlying <span>.

Always aria-hidden="true". Data attributes: data-slot="tabs-progress". Inside a horizontal Tabs.List, the default fill animates scaleX(0 → 1) from the left edge; inside a vertical Tabs.List it switches to scaleY(0 → 1) from the top edge, so the same component works for both orientations without any extra props. The animation is disabled (and the element rendered untransformed at full size) when the user prefers reduced motion.

To customize the timing curve, override the animation-timing-function via className or a stylesheet rule on [data-slot="tabs-progress"]. The default is linear so the bar tracks elapsed time accurately.

Tabs.Separator

Decorative divider between adjacent triggers. Fades out on the active tab via group-data-[state=active]/tab:opacity-0 so the indicator can take its place.

PropTypeDefaultDescription
classNamestringMerged via cn. Defaults position the separator on the appropriate edge depending on orientation.
...restReact.ComponentPropsWithoutRef<"span">Forwarded to the underlying <span>.

Always aria-hidden="true". Data attributes: data-slot="tabs-separator".

Tabs.Content

The role="tabpanel" for a tab. By default, inactive panels are unmounted; pass forceMount to keep them in the DOM with hidden toggled instead.

PropTypeDefaultDescription
idstringrequiredMust match the id of a Tabs.Trigger in the same root.
forceMountbooleanfalseKeep the panel mounted in the DOM when its tab is not selected. Useful for preserving form state or transition libraries.
classNamestringMerged via cn. Defaults to outline-none.
...restOmit<React.ComponentPropsWithoutRef<"div">, "id">Forwarded to the underlying <div>.

Built-in attributes: role="tabpanel", aria-labelledby (matching trigger), hidden (set when inactive and forceMount is true), data-slot="tabs-content", data-state="active" | "inactive", data-orientation.

Keyboard interactions

Direction keys depend on orientation. Disabled triggers are skipped.

KeyAction
Space / EnterActivate the focused trigger (only required when activationMode="manual").
ArrowRight / ArrowDownMove focus to the next trigger (wraps to the first). Right when horizontal, Down when vertical.
ArrowLeft / ArrowUpMove focus to the previous trigger (wraps to the last). Left when horizontal, Up when vertical.
HomeMove focus to the first trigger.
EndMove focus to the last trigger.
Tab / Shift+TabEnters and exits the tablist as a single stop (roving tabIndex).

Exported types

import type { TabsContainerProps, TabsContentProps, TabsIndicatorProps, TabsListProps, TabsOrientation, // "horizontal" | "vertical" TabsProgressProps, TabsRootProps, TabsSeparatorProps, TabsTriggerProps, } from "@togetheragency/ui/tabs";
Last updated on