- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"use client";
import { Tabs } from "@/components/ui/tabs";
const items = [
{
"content": "A clear view of your project, without leaving this page.",
"label": "Overview",
"value": "overview"
},
{
"content": "Recent changes and conversations, all in one place.",
"label": "Activity",
"value": "activity"
},
{
"content": "Manage project preferences and access.",
"label": "Settings",
"value": "settings"
}
];
export function TabsDemo() {
return (
<Tabs aria-label={"Project sections"} variant="underline" orientation="horizontal" animated={true}
activationMode="manual" keepMounted={false}
items={items.map((item) => ({
...item,
disabled: item.value === "activity" && false,
}))}
/>
);
}Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/tabs.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- @base-ui/react@^1.8.0
- cn@^0.4.0
- motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn@^0.4.0 motion@^12.38.0Usage
Declare each panel once. Labels, triggers, panels, and accessible relationships are generated internally.
import { Tabs } from "@/components/ui/tabs";
export function ProjectTabs() {
return (
<Tabs
aria-label="Project sections"
items={[
{
value: "overview",
label: "Overview",
content: <p>Project overview.</p>,
},
{
value: "activity",
label: "Activity",
content: <p>Recent activity.</p>,
},
]}
/>
);
}Use your configured UI alias if it differs. Labels and content accept React nodes. Give panels unique, stable string values; do not put interactive controls inside a tab label. Empty collections render nothing, so asynchronously loaded navigation does not invent a tablist before its links arrive.
Examples
"use client";
import { Tabs } from "@/components/ui/tabs";
export const TabsDemo = () => (
<Tabs
aria-label="Project sections"
items={[
{
content: "Your project at a glance.",
label: "Overview",
value: "overview",
},
{
content: "Recent updates and conversations.",
label: "Activity",
value: "activity",
},
{
content: "Manage your project preferences.",
label: "Settings",
value: "settings",
},
]}
/>
);Variants and Orientation
underline is the default, with a thin active rule. pill adds a rounded accent highlight without a track. segmented uses a muted track with a bordered active surface. All support orientation="vertical"; panel mode places content beside the list. Horizontal lists scroll inside their container instead of wrapping labels.
Router Links, No Adapter
Use a homogeneous collection of link items instead of panels. Put the label inside the link once. Native anchors, Next.js Link, and TanStack Router Link keep their own destination and router-specific props. Tabs does not inspect href or to.
The runnable example intercepts clicks and updates demo-owned active state. It does not navigate to real pages.
"use client";
import Link from "next/link";
import { useState } from "react";
import { Tabs } from "@/components/ui/tabs";
export const TabsLinksDemo = () => {
const [active, setActive] = useState("overview");
return (
<Tabs
aria-label="Project navigation"
variant="pill"
items={["overview", "activity", "settings"].map((section) => ({
active: active === section,
key: section,
link: (
<Link
href={`#${section}`}
onClick={(event) => {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
setActive(section);
}}
>
{section[0].toUpperCase() + section.slice(1)}
</Link>
),
}))}
/>
);
};For TanStack Start, derive active from the router. This illustrative fragment belongs in a client module; Link destinations must exist in your generated route tree:
import { Link, useRouterState } from "@tanstack/react-router";
import { Tabs } from "@/components/ui/tabs";
export function ProjectNavigation() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
return (
<Tabs
aria-label="Project navigation"
items={[
{
link: <Link to="/overview">Overview</Link>,
active: pathname === "/overview",
},
{
link: <Link to="/activity">Activity</Link>,
active: pathname === "/activity",
},
]}
/>
);
}For Next.js, use Link from next/link, href on each element, and usePathname() for route matching. For plain anchors, pass <a href="...">Label</a>. No router package is installed by Tabs.
Mark at most one link item active. Active state is controlled exclusively by your application, so canceled navigation, loading routes, and browser Back/Forward do not get a separate optimistic selection. Match nested routes and query parameters according to your product. For dynamic/reordered link collections, provide a stable item key or a keyed link element.
Links must forward received className, ARIA attributes, event handlers, and ref to a single anchor. Existing handlers and refs are composed using Base UI useRender. Ordinary, modified, and middle clicks remain native link behavior. Tabs does not make links disabled; hide unavailable routes or implement that policy in your application. Do not mix link items and panel items; TypeScript rejects that collection.
Controlled State and Drafts
Use value and onValueChange for controlled panels. Omit defaultValue to start at the first enabled panel; null explicitly selects none. In uncontrolled mode, Base UI falls back when the selected item is removed or disabled; callbacks receive the next value (possibly null) and event details. Call details.cancel() to reject a user-initiated change, not an automatic fallback.
Selected: profile. Edit the name, switch tabs, then return.
"use client";
import { useState } from "react";
import { Tabs } from "@/components/ui/tabs";
export const TabsControlledDemo = () => {
const [value, setValue] = useState<string | null>("profile");
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Tabs
aria-label="Account settings"
variant="segmented"
value={value}
onValueChange={setValue}
keepMounted
items={[
{
content: (
<label className="flex flex-col gap-2">
Display name
<input
className="h-9 rounded-md border bg-background px-3"
defaultValue="Alex"
/>
</label>
),
label: "Profile",
value: "profile",
},
{
content: "Your profile draft is retained when you return.",
label: "Security",
value: "security",
},
{
content: "Unavailable",
disabled: true,
label: "Billing",
value: "billing",
},
]}
/>
<p className="text-xs text-muted-foreground">
Selected: {value ?? "none"}. Edit the name, switch tabs, then return.
</p>
</div>
);
};Inactive panels unmount by default, resetting local state when revisited. keepMounted preserves all panel DOM and local state; inactive panels remain hidden and unavailable for interaction. Account for effects and data fetching in mounted content. Controlled selection must reference an enabled item; Tabs does not silently rewrite application-owned state.
Consumer Setup and Optional Stories
Requires React 19, Tailwind CSS 4 with shadcn theme tokens (background, foreground, muted, muted-foreground, accent, border, ring), @base-ui/react@^1.8.0, cn@^0.4.0, and motion@^12.38.0. The component includes its client boundary and no website-only imports or extra CSS. A Server Component can import it; callbacks and state must originate in a client module. React elements passed from a server still follow your framework's serialization rules.
The registry follows your configured UI alias. Review overwrite prompts if you already have a customized tabs.tsx; the Vandor item-based API differs from shadcn's compound Tabs API.
Install Tabs first, then optionally add its separate stories:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/tabs-stories.json
Use your existing matching @storybook/react packages (typed and composed with 10.6.1), discover **/*.stories.tsx in your UI directory, and load your global Tailwind/theme stylesheet. Stories use relative imports, need no addons, and do not reinstall Tabs or modify Storybook configuration. This project has no Storybook server; Storybook UI verification is not performed.
Behavior & Accessibility
- Panel mode uses Base UI tablist/tab/tabpanel semantics, unique IDs, visible focus, and label relationships. Name the list using
aria-labeloraria-labelledby. - Manual activation is the default: arrows and Home/End move focus; Enter/Space selects.
activationMode="automatic"selects on focus, best for instantly available content. Vertical lists use Up/Down; horizontal lists use Left/Right and preserve normal Up/Down page scrolling. - Disabled panel items cannot be selected, but Base UI keeps them keyboard-focusable so they can be discovered.
- Link mode renders a labeled
navwith actual links andaria-current="page", without invented panel relationships or tab keyboard semantics. - Each instance keeps one indicator canvas anchored to its own tab bar. Motion animates the active surface in local coordinates over 240ms with an ease-out curve, without bounce or a vertical detour when panel height changes. The canvas cannot enlarge the scrollable area; the bar scrolls horizontally only. Panels fade in over 160ms without delaying selection or retaining outgoing interactive panels. Initial active content is visible without a first-paint fade.
animated={false}or a reduced-motion preference makes indicator movement and panel fades immediate.classNamestyles the root,listClassNamethe bar, andcontentClassNameeach panel. Component-specific item props are documented above; arbitrary trigger/panel props and children overrides are not public APIs.
Props
| Prop | Type / Description | Default |
|---|---|---|
| activationMode | "manual" | "automatic"Panel mode: arrows move focus; automatic also selects. Manual uses Enter/Space to activate. | "manual" |
| active | booleanLink item: route-owned active state. Adds aria-current=page; clicks do not change this state. Mark at most one item active. | false |
| animated | booleanMotion indicator and panel fade. Reduced motion always disables movement. | true |
| content | ReactNodePanel item: content rendered in its corresponding panel. Accepts any React node. | Required for panel items |
| contentClassName | stringPanel mode: classes applied to each panel element. | Not set |
| defaultValue | string | nullPanel mode: initial selection. Omitted selects the first enabled item; null selects none. | First enabled item |
| disabled | boolean (item; demo control)Panel item only: prevents selection. Demo disables Activity. Disabled Base UI tabs remain keyboard-focusable. Not a root prop or link-item prop. | false |
| items | readonly TabsPanelItem[] | readonly TabsLinkItem[]Required. One homogeneous collection: value/label/content panels or link/active navigation items. Do not mix both kinds. | Required |
| keepMounted | booleanPanel mode: preserve inactive panel DOM and local state. Hidden panels cannot be interacted with. | false |
| key | stringLink item: optional stable identity for dynamic collections; falls back to the link element key, then index. | Link key or index |
| label | ReactNode (panel item); string (demo control)Panel item: required React node for its trigger. Playground control instead sets the root aria-label; it labels the tablist in panel mode and nav in link mode. | "Project sections" (demo) |
| link | ReactElementLink item: native anchor or router Link, including its label. Must forward DOM props and ref to one anchor. No href/to adapter required. | Required for link items |
| listClassName | stringClass overrides for the tab-bar element. | Not set |
| mode | "panels" | "links" (demo only)Playground composition, not a public prop. Link preview intercepts clicks and updates demo-owned route state without leaving the page. | "panels" (demo) |
| onValueChange | (value, details) => voidPanel mode: Base UI change callback, including fallback changes after item removal/disabling. User changes can be canceled using details.cancel(). | Not set |
| orientation | "horizontal" | "vertical"Tab-bar direction. Panel mode also controls keyboard navigation and panel placement. | "horizontal" |
| value | string | nullPanel item: required unique string identity. On Tabs: controlled active item value; null selects no panel. | Not set |
| variant | "underline" | "pill" | "segmented"Indicator and tab-bar appearance. | "underline" |
Tabs forwards native div attributes/ref to the panel root, or nav attributes/ref to the navigation root, except children. aria-label/aria-labelledby labels the tablist in panel mode and the nav in link mode. Root state/layout, generated children and navigation semantics are managed internally; link elements must forward DOM props and refs to their anchor.
Source
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ComponentProps, ReactElement, ReactNode, RefObject } from "react";
type TabsVariant = "underline" | "pill" | "segmented";
interface TabsPanelItem<Value extends string = string> {
value: Value;
label: ReactNode;
content: ReactNode;
disabled?: boolean;
link?: never;
active?: never;
}
interface TabsLinkItem {
link: ReactElement;
active?: boolean;
/** Optional stable identity for dynamic or reordered link collections. */
key?: string;
value?: never;
label?: never;
content?: never;
disabled?: never;
}
type TabsCommonProps = Omit<
ComponentProps<"div">,
"children" | "defaultValue" | "ref"
> & {
variant?: TabsVariant;
orientation?: "horizontal" | "vertical";
animated?: boolean;
listClassName?: string;
};
type TabsPanelProps<Value extends string = string> = TabsCommonProps & {
items: readonly TabsPanelItem<Value>[];
value?: Value | null;
defaultValue?: Value | null;
onValueChange?: (
value: Value | null,
details: TabsPrimitive.Root.ChangeEventDetails
) => void;
activationMode?: "manual" | "automatic";
keepMounted?: boolean;
contentClassName?: string;
ref?: ComponentProps<"div">["ref"];
};
type TabsNavigationProps = TabsCommonProps & {
items: readonly TabsLinkItem[];
ref?: ComponentProps<"nav">["ref"];
value?: never;
defaultValue?: never;
onValueChange?: never;
activationMode?: never;
keepMounted?: never;
contentClassName?: never;
};
type TabsProps<Value extends string = string> =
| TabsPanelProps<Value>
| TabsNavigationProps;
const transition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
const listClasses = (variant: TabsVariant, vertical: boolean) =>
cn(
"relative isolate flex w-max items-center",
vertical ? "flex-col items-stretch" : "min-w-0",
variant === "underline" &&
(vertical
? "gap-1 border-s border-border"
: "gap-1 border-b border-border"),
variant === "pill" && "gap-1",
variant === "segmented" && "gap-1 rounded-lg bg-muted p-1"
);
const triggerClasses = (variant: TabsVariant) =>
cn(
"relative inline-flex min-h-9 shrink-0 items-center justify-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-active:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variant === "underline" ? "rounded-sm" : "rounded-md"
);
const Indicator = ({
variant,
vertical,
animated,
}: {
variant: TabsVariant;
vertical: boolean;
animated: boolean;
}) => {
const reduceMotion = useReducedMotion();
const enabled = animated && !reduceMotion;
// A single local canvas never projects between trigger parents. Moving a
// centered preview or resizing its panel cannot add a viewport-space detour.
const ref = useRef<SVGSVGElement>(null);
const [box, setBox] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
useLayoutEffect(() => {
const list = ref.current?.parentElement;
if (!list) {
return;
}
const win = list.ownerDocument.defaultView;
if (!win) {
return;
}
const measure = () => {
const active = list.querySelector<HTMLElement>(
'[data-slot="tabs-trigger"][aria-selected="true"], [data-slot="tabs-link"][aria-current="page"]'
);
if (!active) {
setBox(null);
return;
}
const bounds = list.getBoundingClientRect();
const tab = active.getBoundingClientRect();
const scaleX =
list.offsetWidth && bounds.width ? bounds.width / list.offsetWidth : 1;
const scaleY =
list.offsetHeight && bounds.height
? bounds.height / list.offsetHeight
: 1;
let width = tab.width / scaleX;
let height = tab.height / scaleY;
let x =
(tab.left - bounds.left) / scaleX - list.clientLeft + list.scrollLeft;
let y = (tab.top - bounds.top) / scaleY - list.clientTop + list.scrollTop;
const underline = variant === "underline";
if (underline) {
if (vertical) {
x =
win.getComputedStyle(list).direction === "rtl"
? (list.clientWidth || bounds.width / scaleX) - 2
: 0;
y += 8;
width = 2;
height = Math.max(0, height - 16);
} else {
y = (list.clientHeight || bounds.height / scaleY) - 2;
height = 2;
}
}
const next = {
height,
width,
x,
y,
};
setBox((previous) =>
previous &&
previous.x === next.x &&
previous.y === next.y &&
previous.width === next.width &&
previous.height === next.height
? previous
: next
);
};
const resize = new win.ResizeObserver(measure);
const observeSize = () => {
resize.disconnect();
resize.observe(list);
for (const tab of list.querySelectorAll(
'[data-slot="tabs-trigger"], [data-slot="tabs-link"]'
)) {
resize.observe(tab);
}
measure();
};
const mutations = new win.MutationObserver(observeSize);
mutations.observe(list, {
attributeFilter: ["aria-selected", "aria-current", "dir"],
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
observeSize();
win.addEventListener("resize", measure);
return () => {
mutations.disconnect();
resize.disconnect();
win.removeEventListener("resize", measure);
};
}, [variant, vertical]);
return (
<svg
ref={ref}
aria-hidden="true"
focusable="false"
data-slot="tabs-indicator"
className="pointer-events-none absolute inset-0 h-full w-full overflow-hidden"
>
{box && box.width > 0 && box.height > 0 && (
<motion.rect
initial={false}
animate={{
attrX: box.x,
attrY: box.y,
height: box.height,
width: box.width,
}}
transition={enabled ? transition : { duration: 0 }}
rx={variant === "underline" ? 0 : 6}
className={cn(
variant === "underline" && "fill-foreground",
variant === "pill" && "fill-accent",
variant === "segmented" && "fill-background stroke-border"
)}
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
);
};
const TabButton = ({
nativeProps,
}: {
nativeProps: ComponentProps<"button">;
}) =>
useRender({
defaultTagName: "button",
props: {
...nativeProps,
children: (
<span className="relative z-10 inline-flex items-center gap-2">
{nativeProps.children}
</span>
),
},
ref: nativeProps.ref,
});
const NavigationItem = ({
item,
variant,
}: {
item: TabsLinkItem;
variant: TabsVariant;
}) => {
const link = useRender({
defaultTagName: "a",
props: {
"aria-current": item.active ? "page" : undefined,
className: cn(triggerClasses(variant), "z-10 w-full"),
"data-active": item.active ? "" : undefined,
"data-slot": "tabs-link",
},
render: item.link,
});
return <div className="relative shrink-0">{link}</div>;
};
const PanelBody = ({
children,
hidden,
animated,
hydrated,
}: {
children: ReactNode;
hidden: boolean;
animated: boolean;
hydrated: RefObject<boolean>;
}) => {
const reduceMotion = useReducedMotion();
const enabled = animated && !reduceMotion;
return (
<motion.div
initial={enabled && hydrated.current ? { opacity: 0 } : false}
animate={{ opacity: hidden ? 0 : 1 }}
transition={
enabled ? { duration: 0.16, ease: transition.ease } : { duration: 0 }
}
>
{children}
</motion.div>
);
};
const isNavigation = <Value extends string>(
props: TabsProps<Value>
): props is TabsNavigationProps =>
props.items.length > 0 && props.items[0].link !== undefined;
const Tabs = <Value extends string = string>(props: TabsProps<Value>) => {
const id = useId();
const hydrated = useRef(false);
useEffect(() => {
hydrated.current = true;
}, []);
if (props.items.length === 0) {
return null;
}
if (isNavigation(props)) {
const {
items,
variant = "underline",
orientation = "horizontal",
animated = true,
className,
listClassName,
...nativeProps
} = props;
return (
<nav
{...nativeProps}
data-slot="tabs"
data-variant={variant}
data-orientation={orientation}
className={cn("max-w-full", className)}
>
<div
className="max-w-full overflow-x-auto overflow-y-hidden p-1"
style={{ overflowY: "hidden" }}
>
<div
data-slot="tabs-list"
className={cn(
listClasses(variant, orientation === "vertical"),
listClassName
)}
>
{items.map((item, index) => (
<NavigationItem
key={item.key ?? item.link.key ?? index}
item={item}
variant={variant}
/>
))}
<Indicator
variant={variant}
vertical={orientation === "vertical"}
animated={animated}
/>
</div>
</div>
</nav>
);
}
const {
items,
variant = "underline",
orientation = "horizontal",
animated = true,
activationMode = "manual",
keepMounted = false,
className,
listClassName,
contentClassName,
defaultValue,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
...rootProps
} = props as TabsPanelProps<Value>;
return (
<TabsPrimitive.Root
{...rootProps}
defaultValue={
defaultValue === undefined
? (items.find((item) => !item.disabled)?.value ?? null)
: defaultValue
}
orientation={orientation}
data-slot="tabs"
data-variant={variant}
className={cn(
"flex min-w-0 gap-4",
orientation === "vertical" ? "flex-row" : "flex-col",
className
)}
>
<div
className="max-w-full shrink-0 overflow-x-auto overflow-y-hidden p-1"
style={{ overflowY: "hidden" }}
>
<TabsPrimitive.List
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
activateOnFocus={activationMode === "automatic"}
data-slot="tabs-list"
className={cn(
listClasses(variant, orientation === "vertical"),
listClassName
)}
>
{items.map((item) => (
<TabsPrimitive.Tab
key={item.value}
value={item.value}
id={`${id}-tab-${encodeURIComponent(item.value)}`}
disabled={item.disabled}
data-slot="tabs-trigger"
className={triggerClasses(variant)}
render={(nativeProps, state) => (
<TabButton
nativeProps={{
...nativeProps,
"aria-controls":
state.active || keepMounted
? `${id}-panel-${encodeURIComponent(item.value)}`
: undefined,
}}
/>
)}
>
{item.label}
</TabsPrimitive.Tab>
))}
<Indicator
variant={variant}
vertical={orientation === "vertical"}
animated={animated}
/>
</TabsPrimitive.List>
</div>
{items.map((item) => (
<TabsPrimitive.Panel
key={item.value}
value={item.value}
id={`${id}-panel-${encodeURIComponent(item.value)}`}
aria-labelledby={`${id}-tab-${encodeURIComponent(item.value)}`}
keepMounted={keepMounted}
data-slot="tabs-content"
className={cn(
"min-w-0 flex-1 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
contentClassName
)}
render={(nativeProps, state) => (
<div {...nativeProps}>
<PanelBody
hidden={state.hidden}
animated={animated}
hydrated={hydrated}
>
{nativeProps.children}
</PanelBody>
</div>
)}
>
{item.content}
</TabsPrimitive.Panel>
))}
</TabsPrimitive.Root>
);
};
export { Tabs };
export type {
TabsLinkItem,
TabsNavigationProps,
TabsPanelItem,
TabsPanelProps,
TabsProps,
TabsVariant,
};