Tabs
A component for switching between different content panels within the same view, keeping related content organized without page navigation.
Use Cases
- Switch between different sections of a settings page
- Toggle between code examples in different languages
- Organize product details (description, specs, reviews)
- Filter dashboard views (overview, analytics, logs)
- Display different time ranges for charts or data
- Navigate between steps in a multi-part form
- Show different categories of content on a profile page
Simplest Implementation
"use client";
import { useState } from "react";
interface Tab {
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
}
export function Tabs({ tabs }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div>
<div className="flex border-b border-gray-200">
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeIndex === index
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="py-4">{tabs[activeIndex].content}</div>
</div>
);
}A minimal tab switcher using index-based state. The active tab gets a blue bottom border. Only the active panel renders, keeping the DOM small.
Variations
Underline Style
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div>
<div className="flex gap-8 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`relative pb-3 text-sm font-medium transition-colors ${
activeId === tab.id
? "text-gray-900"
: "text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
{activeId === tab.id && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-gray-900" />
)}
</button>
))}
</div>
<div className="py-6">
{tabs.find((t) => t.id === activeId)?.content}
</div>
</div>
);
}Uses string IDs instead of indices so tabs can be reordered without breaking state. The active indicator is an absolute-positioned span, making it easy to animate later with a layout transition.
Pill Style
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div>
<div className="inline-flex gap-1 rounded-lg bg-gray-100 p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`rounded-md px-4 py-2 text-sm font-medium transition-all ${
activeId === tab.id
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="py-6">
{tabs.find((t) => t.id === activeId)?.content}
</div>
</div>
);
}A segmented control look where the active tab has a white background with shadow. The outer container uses bg-gray-100 with p-1 to create the inset effect.
Vertical Tabs
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface VerticalTabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function VerticalTabs({ tabs, defaultTab }: VerticalTabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div className="flex gap-8">
<div className="flex w-48 shrink-0 flex-col border-r border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`px-4 py-2 text-left text-sm font-medium transition-colors ${
activeId === tab.id
? "border-r-2 border-blue-600 bg-blue-50 text-blue-600"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1">{tabs.find((t) => t.id === activeId)?.content}</div>
</div>
);
}Stacks tabs vertically on the left with a right border indicator. The w-48 shrink-0 keeps the tab list a fixed width while the content panel fills the remaining space.
Tabs with Icons
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
icon: React.ReactNode;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div>
<div className="flex gap-1 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeId === tab.id
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
<span className="h-4 w-4">{tab.icon}</span>
{tab.label}
</button>
))}
</div>
<div className="py-4">{tabs.find((t) => t.id === activeId)?.content}</div>
</div>
);
}
// Usage:
// <Tabs tabs={[
// {
// id: "overview",
// label: "Overview",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3" /></svg>,
// content: <p>Overview content</p>,
// },
// ]} />Each tab accepts an icon ReactNode displayed before the label. The fixed h-4 w-4 wrapper keeps icons consistently sized regardless of the SVG's native dimensions.
Controlled Tabs
"use client";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface ControlledTabsProps {
tabs: Tab[];
activeTab: string;
onTabChange: (id: string) => void;
}
export function ControlledTabs({ tabs, activeTab, onTabChange }: ControlledTabsProps) {
return (
<div>
<div className="flex border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="py-4">{tabs.find((t) => t.id === activeTab)?.content}</div>
</div>
);
}
// Usage:
// const [tab, setTab] = useState("general");
//
// // Sync with URL search params
// const searchParams = useSearchParams();
// useEffect(() => {
// const t = searchParams.get("tab");
// if (t) setTab(t);
// }, [searchParams]);
//
// <ControlledTabs tabs={tabs} activeTab={tab} onTabChange={setTab} />The parent owns the active state, making it easy to sync with URL search params, persist to localStorage, or coordinate with other components. No internal useState means the component is a pure controlled input.
Complex Implementation
"use client";
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useId,
useEffect,
type KeyboardEvent,
} from "react";
// --- Context ---
interface TabsContextValue {
activeId: string;
setActiveId: (id: string) => void;
registerTab: (id: string) => void;
tabIds: React.MutableRefObject<string[]>;
orientation: "horizontal" | "vertical";
baseId: string;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs components must be used inside Tabs.Root");
return ctx;
}
// --- Root ---
interface TabsRootProps {
defaultTab: string;
orientation?: "horizontal" | "vertical";
onChange?: (id: string) => void;
children: React.ReactNode;
className?: string;
}
export function TabsRoot({
defaultTab,
orientation = "horizontal",
onChange,
children,
className,
}: TabsRootProps) {
const [activeId, setActiveIdState] = useState(defaultTab);
const tabIds = useRef<string[]>([]);
const baseId = useId();
const setActiveId = useCallback(
(id: string) => {
setActiveIdState(id);
onChange?.(id);
},
[onChange]
);
const registerTab = useCallback((id: string) => {
tabIds.current = [...new Set([...tabIds.current, id])];
}, []);
return (
<TabsContext.Provider value={{ activeId, setActiveId, registerTab, tabIds, orientation, baseId }}>
<div
className={`${orientation === "vertical" ? "flex gap-8" : ""} ${className ?? ""}`}
>
{children}
</div>
</TabsContext.Provider>
);
}
// --- Tab List ---
interface TabListProps {
children: React.ReactNode;
className?: string;
variant?: "underline" | "pill";
}
export function TabList({ children, className, variant = "underline" }: TabListProps) {
const { orientation, tabIds, setActiveId, activeId } = useTabsContext();
const listRef = useRef<HTMLDivElement>(null);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const ids = tabIds.current;
const currentIndex = ids.indexOf(activeId);
let nextIndex = currentIndex;
const isHorizontal = orientation === "horizontal";
const prev = isHorizontal ? "ArrowLeft" : "ArrowUp";
const next = isHorizontal ? "ArrowRight" : "ArrowDown";
if (e.key === next) {
e.preventDefault();
nextIndex = (currentIndex + 1) % ids.length;
} else if (e.key === prev) {
e.preventDefault();
nextIndex = (currentIndex - 1 + ids.length) % ids.length;
} else if (e.key === "Home") {
e.preventDefault();
nextIndex = 0;
} else if (e.key === "End") {
e.preventDefault();
nextIndex = ids.length - 1;
} else {
return;
}
setActiveId(ids[nextIndex]);
const nextButton = listRef.current?.querySelector(
`[data-tab-id="${ids[nextIndex]}"]`
) as HTMLElement | null;
nextButton?.focus();
}
const baseClasses =
variant === "pill"
? "inline-flex gap-1 rounded-lg bg-gray-100 p-1"
: orientation === "vertical"
? "flex w-48 shrink-0 flex-col border-r border-gray-200"
: "flex gap-1 border-b border-gray-200";
return (
<div
ref={listRef}
role="tablist"
aria-orientation={orientation}
onKeyDown={handleKeyDown}
className={`${baseClasses} ${className ?? ""}`}
>
{children}
</div>
);
}
// --- Tab ---
interface TabProps {
id: string;
children: React.ReactNode;
disabled?: boolean;
}
export function Tab({ id, children, disabled = false }: TabProps) {
const { activeId, setActiveId, registerTab, orientation, baseId } = useTabsContext();
const isActive = activeId === id;
useEffect(() => {
registerTab(id);
}, [id, registerTab]);
return (
<button
role="tab"
id={`${baseId}-tab-${id}`}
data-tab-id={id}
aria-selected={isActive}
aria-controls={`${baseId}-panel-${id}`}
aria-disabled={disabled}
tabIndex={isActive ? 0 : -1}
onClick={() => !disabled && setActiveId(id)}
className={`relative text-sm font-medium transition-colors ${
disabled
? "cursor-not-allowed text-gray-300"
: isActive
? orientation === "vertical"
? "border-r-2 border-blue-600 bg-blue-50 px-4 py-2 text-left text-blue-600"
: "border-b-2 border-blue-600 px-4 py-2.5 text-blue-600"
: orientation === "vertical"
? "px-4 py-2 text-left text-gray-600 hover:bg-gray-50 hover:text-gray-900"
: "px-4 py-2.5 text-gray-500 hover:text-gray-700"
}`}
>
{children}
</button>
);
}
// --- Panel ---
interface TabPanelProps {
id: string;
children: React.ReactNode;
}
export function TabPanel({ id, children }: TabPanelProps) {
const { activeId, baseId } = useTabsContext();
const isActive = activeId === id;
if (!isActive) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${id}`}
aria-labelledby={`${baseId}-tab-${id}`}
tabIndex={0}
className="flex-1 py-4 focus-visible:outline-none"
>
{children}
</div>
);
}
// --- Usage Example ---
// <TabsRoot defaultTab="general" orientation="horizontal" onChange={(id) => console.log(id)}>
// <TabList variant="underline">
// <Tab id="general">General</Tab>
// <Tab id="security">Security</Tab>
// <Tab id="billing">Billing</Tab>
// <Tab id="advanced" disabled>Advanced</Tab>
// </TabList>
// <TabPanel id="general">General settings content</TabPanel>
// <TabPanel id="security">Security settings content</TabPanel>
// <TabPanel id="billing">Billing settings content</TabPanel>
// <TabPanel id="advanced">Advanced settings content</TabPanel>
// </TabsRoot>Key aspects:
- Compound component pattern —
TabsRoot,TabList,Tab, andTabPanelcompose freely. State flows through context so child components don't need prop drilling. - Full keyboard navigation — Arrow keys move between tabs, Home/End jump to first/last, and focus follows selection. The direction flips for vertical orientation.
- Roving tabindex — only the active tab has
tabIndex={0}. All others aretabIndex={-1}, so Tab key moves focus in and out of the tab list rather than through every tab. - ARIA roles —
role="tablist",role="tab",role="tabpanel",aria-selected,aria-controls, andaria-labelledbymatch the WAI-ARIA Tabs pattern exactly. - useId for stable IDs — React 19's
useIdgenerates SSR-safe IDs for linking tabs to their panels. No collision risk with multiple tab instances on the same page. - Orientation support —
aria-orientationand keyboard direction adapt to"horizontal"or"vertical", so the same component works for both top-nav and sidebar layouts. - Disabled tabs — disabled tabs are visually grayed out, excluded from click handling, but remain in the keyboard navigation order with
aria-disabledfor discoverability.
Gotchas
-
All tabs with
tabIndex={0}— This forces keyboard users to tab through every tab button before reaching the panel. Use roving tabindex: only the active tab getstabIndex={0}, others gettabIndex={-1}. -
Missing
role="tablist"/role="tab"/role="tabpanel"— Without proper ARIA roles, screen readers treat tabs as regular buttons. Always apply the full set of tab-related roles and attributes. -
Panel content unmounts on tab switch — Conditionally rendering panels destroys component state (form inputs, scroll position). If state preservation matters, render all panels and hide inactive ones with
hiddenordisplay: none. -
Tab state not synced with URL — Users can't share or bookmark a specific tab. Use
useSearchParamsto sync the active tab with a URL query parameter for deep linking. -
Horizontal overflow on mobile — Too many tabs overflow the container on small screens. Add
overflow-x-autoandscrollbar-hideto the tab list, or switch to a dropdown/select on mobile. -
Animated indicator jumping — A CSS bottom-border jumps between tabs. For a sliding indicator, measure the active tab's
offsetLeftandoffsetWidthwith a ref and animate a positioned element.