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.Rootrenders a<div>and owns selection. PassdefaultValueorvalue+onValueChangeto choose between uncontrolled and controlled.Tabs.Containeris aposition: relativewrapper aroundTabs.List. It provides the positioning context the absolutely-renderedTabs.Indicatorneeds — required whenever you use an indicator.Tabs.Listis therole="tablist"group, witharia-orientationderived from the root.Tabs.Triggeris the realrole="tab"<button>. Each one carries anidthat ties it to aTabs.Contentwith the sameid.Tabs.Indicatoris an optional sliding marker. Rendered only inside the currently selected trigger; motion’s sharedlayoutIdanimates the marker between triggers when selection moves.Tabs.Progressis an optional CSS-driven progress indicator. Renders only whenTabs.Roothasautoplayenabled and only inside the currently selected trigger. Its animation duration tracksautoplayDelayvia the--tabs-autoplay-durationcustom property — no React state per frame.Tabs.Separatoris an optional decorative divider between adjacent triggers. Fades out on the active tab so the indicator can take its place.Tabs.Contentis therole="tabpanel"for an id. By default, inactive panels are unmounted — passforceMountto 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.Contentwhoseidfollows it. - Pass
forceMounton thatTabs.Content. Without it, the moment the active id changes,Tabs.Contentreturnsnullfor the deselecting panel — that tears down theAnimatePresenceinside before it can runexit, and you get a snap instead of a transition.forceMountkeeps the panel element mounted across the change so AnimatePresence stays alive. forceMountalso adds a briefhiddenattribute on the same-tick render before selection state settles. Override itsdisplay: nonewith 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.Indicatortransitionprop 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.Contentwhoseidtracks the active value keeps the WAI-ARIA wiring intact (aria-controls/aria-labelledbystill match) while lettingAnimatePresencesee 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"(dropmode="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.
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.
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.
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.
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.
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.
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.
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.
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.
Exported types
import type {
TabsContainerProps,
TabsContentProps,
TabsIndicatorProps,
TabsListProps,
TabsOrientation, // "horizontal" | "vertical"
TabsProgressProps,
TabsRootProps,
TabsSeparatorProps,
TabsTriggerProps,
} from "@togetheragency/ui/tabs";