Navs & Tabs

Tab navigation with underline indicator, context-driven active state, and composable tab groups with panels for organized content switching.

Preview

Basic tabs

import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@/registry/ui-kit/tailwind-navs-tabs/react'

function Example() {
  return (
    <TabGroup>
      <TabList>
        <Tab index={0}>Account</Tab>
        <Tab index={1}>Notifications</Tab>
        <Tab index={2}>Billing</Tab>
      </TabList>
      <TabPanels>
        <TabPanel index={0}>
          <p>Account settings content here.</p>
        </TabPanel>
        <TabPanel index={1}>
          <p>Notification preferences content here.</p>
        </TabPanel>
        <TabPanel index={2}>
          <p>Billing and plan details here.</p>
        </TabPanel>
      </TabPanels>
    </TabGroup>
  )
}

Component API

PropDefaultDescription
TabGroupState wrapper that manages active tab index via React context.
defaultIndex0Initial active tab index.
TabListContainer for tab buttons with bottom border.
TabTab button with active underline indicator. Requires index prop.
indexZero-based index identifying the tab and its corresponding panel.
TabPanelsContainer for tab panel content.
TabPanelContent panel shown when its index matches the active tab. Requires index prop.

Source Code

"use client";

import { createContext, useContext, useState } from "react";

/* ── Context ── */

interface TabContextValue {
  activeIndex: number;
  setActiveIndex: (index: number) => void;
}

const TabContext = createContext<TabContextValue>({
  activeIndex: 0,
  setActiveIndex: () => {},
});

/* ── TabGroup ── */

interface TabGroupProps {
  children: React.ReactNode;
  defaultIndex?: number;
  className?: string;
}

function TabGroup({ children, defaultIndex = 0, className = "" }: TabGroupProps) {
  const [activeIndex, setActiveIndex] = useState(defaultIndex);

  return (
    <TabContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className={className}>{children}</div>
    </TabContext.Provider>
  );
}

/* ── TabList ── */

interface TabListProps {
  children: React.ReactNode;
  className?: string;
}

function TabList({ children, className = "" }: TabListProps) {
  return (
    <div
      className={`flex gap-1 border-b border-zinc-200 dark:border-zinc-800 ${className}`}
      role="tablist"
    >
      {children}
    </div>
  );
}

/* ── Tab ── */

interface TabProps {
  children: React.ReactNode;
  index: number;
  className?: string;
}

function Tab({ children, index, className = "" }: TabProps) {
  const { activeIndex, setActiveIndex } = useContext(TabContext);
  const isActive = activeIndex === index;

  return (
    <button
      role="tab"
      type="button"
      aria-selected={isActive}
      className={`relative px-4 py-2.5 text-sm font-medium transition-colors ${
        isActive
          ? "text-indigo-600 dark:text-indigo-400 after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-indigo-600 dark:after:bg-indigo-400"
          : "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
      } ${className}`}
      onClick={() => setActiveIndex(index)}
    >
      {children}
    </button>
  );
}

/* ── TabPanels ── */

interface TabPanelsProps {
  children: React.ReactNode;
  className?: string;
}

function TabPanels({ children, className = "" }: TabPanelsProps) {
  return <div className={className}>{children}</div>;
}

/* ── TabPanel ── */

interface TabPanelProps {
  children: React.ReactNode;
  index: number;
  className?: string;
}

function TabPanel({ children, index, className = "" }: TabPanelProps) {
  const { activeIndex } = useContext(TabContext);

  if (activeIndex !== index) return null;

  return (
    <div role="tabpanel" className={`py-4 ${className}`}>
      {children}
    </div>
  );
}

export { TabGroup, TabList, Tab, TabPanels, TabPanel };
export default TabGroup;