React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

tabsnavigationpanelcomponenttailwind

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 patternTabsRoot, TabList, Tab, and TabPanel compose 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 are tabIndex={-1}, so Tab key moves focus in and out of the tab list rather than through every tab.
  • ARIA rolesrole="tablist", role="tab", role="tabpanel", aria-selected, aria-controls, and aria-labelledby match the WAI-ARIA Tabs pattern exactly.
  • useId for stable IDs — React 19's useId generates SSR-safe IDs for linking tabs to their panels. No collision risk with multiple tab instances on the same page.
  • Orientation supportaria-orientation and 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-disabled for 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 gets tabIndex={0}, others get tabIndex={-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 hidden or display: none.

  • Tab state not synced with URL — Users can't share or bookmark a specific tab. Use useSearchParams to 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-auto and scrollbar-hide to 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 offsetLeft and offsetWidth with a ref and animate a positioned element.

  • Button — Interactive elements used within tab panels
  • Accordion — Alternative pattern for collapsible content sections
  • Card — Card layouts often used as tab panel content