Composite
Tabs
Tabs accepts a `tabs` array of title/content items with optional icons and badges. Supports controlled index selection, scroll buttons, first/last navigation, and animated panel height on switch.
Installation
npx shadcn add https://rck.vibeboxph.com//r/tabs.jsonLive preview & controls
Overview panel content.
Controls
<Tabs
tabs={[
{ title: "Overview", content: "Overview panel content." },
{ title: "Usage", content: "Usage panel content." },
]}
/>Anatomy
TabsRoot component rendering the tab strip and active panel.tabs[].title / contentTab label and panel body for each item.tabs[].icon / badgeOptional leading icon and count badge on a tab.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | TabItem[] | — | Tab definitions with title, content, disabled, icon, and badge. |
| activeTab / defaultActiveTab | number | — | Controlled / uncontrolled active tab index. |
| onTabChange | (index: number) => void | — | Fires when the active tab changes. |
| variant | "underline" | "pill" | "outline" | pill | Visual style for the tab strip. |
| orientation | "horizontal" | "vertical" | horizontal | Tab list layout; switches arrow-key axis. |
| fullHeader | boolean | false | Stretch tabs to fill the header width. |
| firstLastNavControl | boolean | false | Show jump-to-first/last tab buttons. |
| className | string | — | Merged onto the root tabs container. |
| id (TabItem) | string | — | Optional stable id for the tab panel. |
| iconClassName (TabItem) | string | — | Extra classes on the tab icon. |
| badgeVariant (TabItem) | Badge variant | — | Visual style for the tab count badge. |
Accessibility
- Implements the WAI-ARIA Tabs pattern: `role="tablist"`/`"tab"`/`"tabpanel"` with `aria-selected` and `aria-controls`.
- Arrow Left/Right (horizontal) or Up/Down (vertical) move focus; Enter/Space activates the focused tab.
- Home/End jump to the first or last enabled tab. Disabled tabs are skipped when navigating.
Source
registry/rck/ui/tabs.tsx
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { Badge, type BadgeProps } from "@/registry/rck/ui/badge";
import { Icon, type IconName } from "@/registry/rck/ui/icon";
export type TabItem = {
id?: string;
title: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
icon?: IconName;
iconClassName?: string;
badge?: number;
badgeVariant?: BadgeProps["variant"];
};
export interface TabsProps {
tabs: TabItem[];
activeTab?: number;
defaultActiveTab?: number;
onTabChange?: (index: number) => void;
variant?: "underline" | "pill" | "outline";
orientation?: "horizontal" | "vertical";
fullHeader?: boolean;
firstLastNavControl?: boolean;
className?: string;
}
type TabsVariant = NonNullable<TabsProps["variant"]>;
type TabsOrientation = NonNullable<TabsProps["orientation"]>;
function getInitialTab(tabs: TabItem[], preferred?: number) {
if (preferred != null && tabs[preferred] && !tabs[preferred].disabled) return preferred;
const firstEnabled = tabs.findIndex((tab) => !tab.disabled);
return firstEnabled >= 0 ? firstEnabled : 0;
}
function tabListClasses(
variant: TabsVariant,
orientation: TabsOrientation,
fullHeader?: boolean,
) {
return cn(
"inline-flex min-h-9 items-center",
fullHeader && orientation === "horizontal" && "w-full",
orientation === "vertical" && "h-fit flex-col items-stretch",
variant === "underline" &&
cn(
orientation === "horizontal"
? "gap-1 border-b border-border px-1"
: "gap-1 border-r border-border pr-3",
),
variant === "pill" && "gap-1 rounded-lg bg-surface-2 p-1",
variant === "outline" && "gap-1 rounded-lg border border-border bg-card p-1",
);
}
function tabTriggerClasses(
variant: TabsVariant,
orientation: TabsOrientation,
active: boolean,
fullHeader?: boolean,
) {
return cn(
"inline-flex min-h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium",
"outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
fullHeader && orientation === "horizontal" && "flex-1",
variant === "underline" &&
cn(
orientation === "horizontal"
? cn(
"rounded-none border-b-2 border-transparent px-3 pb-2.5 text-muted-foreground",
active && "border-primary text-foreground",
!active && "hover:bg-surface-2/60 hover:text-foreground",
)
: cn(
"rounded-none border-r-2 border-transparent px-3 py-2 text-left text-muted-foreground",
active && "border-primary text-foreground",
!active && "hover:bg-surface-2/60 hover:text-foreground",
),
),
variant === "pill" &&
cn(
"text-muted-foreground",
active && "bg-background text-foreground shadow-sm",
!active && "hover:bg-background/60 hover:text-foreground",
),
variant === "outline" &&
cn(
"text-muted-foreground",
active && "bg-primary text-primary-foreground shadow-sm",
!active && "hover:bg-surface-2 hover:text-foreground",
),
);
}
export function Tabs({
tabs,
activeTab,
defaultActiveTab,
onTabChange,
variant = "pill",
orientation = "horizontal",
fullHeader = false,
firstLastNavControl = false,
className,
}: TabsProps) {
const baseId = React.useId();
const tabListRef = React.useRef<HTMLDivElement>(null);
const panelOuterRef = React.useRef<HTMLDivElement>(null);
const panelInnerRef = React.useRef<HTMLDivElement>(null);
const lockedRef = React.useRef<number | null>(null);
const firstRevealRef = React.useRef(true);
const heightUnlockTimerRef = React.useRef<number | null>(null);
const roRef = React.useRef<ResizeObserver | null>(null);
const [focusedTab, setFocusedTab] = React.useState(() =>
getInitialTab(tabs, defaultActiveTab),
);
const [isScrollable, setIsScrollable] = React.useState(false);
const [lockedHeight, setLockedHeight] = React.useState<number | null>(null);
const [selectedTab, setSelectedTab] = useControllableState<number>({
prop: activeTab,
defaultProp: getInitialTab(tabs, defaultActiveTab),
onChange: onTabChange,
});
const checkScrollable = React.useCallback(() => {
const list = tabListRef.current;
if (!list) return;
if (orientation === "horizontal") {
setIsScrollable(list.scrollWidth > list.clientWidth + 1);
} else {
setIsScrollable(list.scrollHeight > list.clientHeight + 1);
}
}, [orientation]);
React.useEffect(() => {
lockedRef.current = lockedHeight;
}, [lockedHeight]);
const clearHeightTimer = React.useCallback(() => {
if (heightUnlockTimerRef.current != null) {
window.clearTimeout(heightUnlockTimerRef.current);
heightUnlockTimerRef.current = null;
}
}, []);
const unlockHeightSoon = React.useCallback(
(ms = 240) => {
clearHeightTimer();
heightUnlockTimerRef.current = window.setTimeout(() => {
setLockedHeight(null);
heightUnlockTimerRef.current = null;
}, ms);
},
[clearHeightTimer],
);
const getWrapperPaddingY = React.useCallback((outer: HTMLElement) => {
const cs = getComputedStyle(outer);
return parseFloat(cs.paddingTop || "0") + parseFloat(cs.paddingBottom || "0");
}, []);
const measurePrevHeight = React.useCallback((): number => {
const outer = panelOuterRef.current;
if (outer) return outer.offsetHeight;
const inner = panelInnerRef.current;
return inner ? inner.offsetHeight : 0;
}, []);
const measureNaturalHeight = React.useCallback((): number => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
if (!outer) return 0;
const scrollH = outer.scrollHeight;
if (scrollH > 0) return scrollH;
if (inner) {
const padY = getWrapperPaddingY(outer);
return inner.offsetHeight + padY;
}
return 0;
}, [getWrapperPaddingY]);
React.useEffect(() => {
return () => {
clearHeightTimer();
roRef.current?.disconnect();
};
}, [clearHeightTimer]);
React.useEffect(() => {
if (activeTab !== undefined) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- lock height during tab cross-fade
setLockedHeight(measurePrevHeight());
setFocusedTab(activeTab);
}
}, [activeTab, measurePrevHeight]);
React.useEffect(() => {
checkScrollable();
window.addEventListener("resize", checkScrollable);
const list = tabListRef.current;
let listObserver: ResizeObserver | undefined;
if (list && typeof ResizeObserver !== "undefined") {
listObserver = new ResizeObserver(checkScrollable);
listObserver.observe(list);
}
return () => {
window.removeEventListener("resize", checkScrollable);
listObserver?.disconnect();
};
}, [tabs, orientation, checkScrollable]);
React.useEffect(() => {
let frame = 0;
frame = requestAnimationFrame(() => {
const targetH = measureNaturalHeight();
if (!targetH || targetH <= 0) return;
if (firstRevealRef.current) {
setLockedHeight(targetH);
unlockHeightSoon(0);
firstRevealRef.current = false;
return;
}
if (lockedRef.current == null) {
setLockedHeight(measurePrevHeight());
}
if (lockedRef.current !== targetH) {
setLockedHeight(targetH);
unlockHeightSoon(320);
}
});
return () => cancelAnimationFrame(frame);
}, [selectedTab, measureNaturalHeight, measurePrevHeight, unlockHeightSoon]);
React.useEffect(() => {
const inner = panelInnerRef.current;
if (!inner || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
if (lockedRef.current == null) return;
const nextH = measureNaturalHeight();
if (!nextH || nextH <= 0) return;
if (nextH !== lockedRef.current) setLockedHeight(nextH);
unlockHeightSoon(360);
});
ro.observe(inner);
roRef.current = ro;
return () => {
ro.disconnect();
roRef.current = null;
};
}, [selectedTab, measureNaturalHeight, unlockHeightSoon]);
if (!tabs.length) return null;
const getTabId = (index: number) => `${baseId}-tab-${index}`;
const getPanelId = (index: number) => `${baseId}-panel-${index}`;
const ensureTabVisible = (index: number) => {
const el = tabListRef.current?.children[index] as HTMLElement | undefined;
el?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: orientation === "horizontal" ? "center" : "nearest",
});
};
const handleTabChange = (index: number) => {
if (!tabs[index] || tabs[index].disabled || selectedTab === index) return;
setLockedHeight(measurePrevHeight());
setSelectedTab(index);
setFocusedTab(index);
ensureTabVisible(index);
};
const findNextEnabled = (start: number, direction: 1 | -1) => {
let index = start;
do {
index += direction;
} while (index >= 0 && index < tabs.length && tabs[index]?.disabled);
return index >= 0 && index < tabs.length ? index : null;
};
const moveFocus = (direction: 1 | -1) => {
const next = findNextEnabled(focusedTab, direction);
if (next != null) {
setFocusedTab(next);
ensureTabVisible(next);
(tabListRef.current?.children[next] as HTMLElement | undefined)?.focus();
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
const isHorizontal = orientation === "horizontal";
const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp";
const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown";
if (event.key === prevKey) {
event.preventDefault();
moveFocus(-1);
} else if (event.key === nextKey) {
event.preventDefault();
moveFocus(1);
} else if (event.key === "Home") {
event.preventDefault();
const first = getInitialTab(tabs, 0);
setFocusedTab(first);
ensureTabVisible(first);
} else if (event.key === "End") {
event.preventDefault();
for (let i = tabs.length - 1; i >= 0; i -= 1) {
if (!tabs[i]?.disabled) {
setFocusedTab(i);
ensureTabVisible(i);
break;
}
}
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleTabChange(focusedTab);
}
};
const scrollTabs = (direction: 1 | -1) => {
const list = tabListRef.current;
if (!list) return;
const delta =
orientation === "horizontal" ? list.clientWidth * 0.6 : list.clientHeight * 0.6;
if (orientation === "horizontal") {
list.scrollBy({ left: direction * delta, behavior: "smooth" });
} else {
list.scrollBy({ top: direction * delta, behavior: "smooth" });
}
};
const navButtonClass = cn(
"inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card",
"text-muted-foreground shadow-sm transition-colors",
"hover:bg-surface-2 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
);
const showHeaderChrome = variant !== "underline";
return (
<div
data-slot="tabs"
className={cn(
orientation === "vertical" && "flex gap-4",
orientation === "horizontal" && "w-full",
className,
)}
>
<div
className={cn(
"relative flex items-center gap-1.5",
orientation === "vertical" && "flex-col",
orientation === "horizontal" && "w-full",
showHeaderChrome && "border-border bg-card rounded-lg border p-1.5",
)}
>
{firstLastNavControl ? (
<button
type="button"
className={navButtonClass}
aria-label="First tab"
onClick={() => handleTabChange(getInitialTab(tabs, 0))}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</button>
) : null}
{isScrollable ? (
<button
type="button"
className={navButtonClass}
aria-label="Scroll tabs backward"
onClick={() => scrollTabs(-1)}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
) : null}
<div className="relative min-w-0 flex-1">
{isScrollable && orientation === "horizontal" ? (
<>
<div
aria-hidden="true"
className="from-background pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r to-transparent"
/>
<div
aria-hidden="true"
className="from-background pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l to-transparent"
/>
</>
) : null}
<div
ref={tabListRef}
role="tablist"
aria-orientation={orientation}
className={cn(
tabListClasses(variant, orientation, fullHeader),
isScrollable &&
cn(
"scrollbar-none overflow-auto",
orientation === "horizontal" ? "w-full flex-nowrap" : "max-h-48",
),
)}
onKeyDown={handleKeyDown}
>
{tabs.map((tab, index) => {
const active = selectedTab === index;
const tabId = tab.id ?? getTabId(index);
return (
<button
key={tabId}
type="button"
role="tab"
id={getTabId(index)}
aria-selected={active}
aria-controls={getPanelId(index)}
aria-disabled={tab.disabled || undefined}
tabIndex={active ? 0 : -1}
disabled={tab.disabled}
className={tabTriggerClasses(variant, orientation, active, fullHeader)}
onFocus={() => !tab.disabled && setFocusedTab(index)}
onClick={() => handleTabChange(index)}
onKeyDown={handleKeyDown}
>
{tab.icon ? (
<Icon name={tab.icon} size="sm" className={tab.iconClassName} />
) : null}
<span>{tab.title}</span>
{tab.badge != null ? (
<Badge variant={tab.badgeVariant ?? "destructive"} size="xs">
{tab.badge}
</Badge>
) : null}
</button>
);
})}
</div>
</div>
{isScrollable ? (
<button
type="button"
className={navButtonClass}
aria-label="Scroll tabs forward"
onClick={() => scrollTabs(1)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
) : null}
{firstLastNavControl ? (
<button
type="button"
className={navButtonClass}
aria-label="Last tab"
onClick={() => {
for (let i = tabs.length - 1; i >= 0; i -= 1) {
if (!tabs[i]?.disabled) {
handleTabChange(i);
break;
}
}
}}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</button>
) : null}
</div>
<div
ref={panelOuterRef}
className={cn(
"overflow-hidden transition-[height] duration-300 ease-out",
orientation === "horizontal" ? "mt-4 w-full" : "min-w-0 flex-1 pt-1",
)}
style={{ height: lockedHeight != null ? lockedHeight : undefined }}
onTransitionEnd={(event) => {
if (event.propertyName === "height") setLockedHeight(null);
}}
>
<div
ref={panelInnerRef}
role="tabpanel"
id={getPanelId(selectedTab)}
aria-labelledby={getTabId(selectedTab)}
className="focus-visible:ring-ring/50 rounded-md outline-none focus-visible:ring-2"
>
{tabs[selectedTab]?.content ?? null}
</div>
</div>
</div>
);
}
Related examples
Scroll & first/last navOverflow tab strip with scroll chevrons and jump buttons.
Visual variantsUnderline and pill tab strips.
Vertical layoutSide navigation with a disabled tab.
ControlledActive tab driven by an external button.
Keyboard behaviorThree tabs for exploring arrow-key navigation.