GitHub

Tabs

A context-driven tab set. Supports controlled and uncontrolled modes, disabled tabs, and two visual variants.

AccessibleDark ModeControlledUncontrolled2 VariantsCompound
01

Theme Preview

Both variants — underline and pills — rendered with forced light and dark styling.

Light

Underline

Overview
Activity
Settings

Overview

Project summary and key metrics.

Pills

Overview
Activity
Settings

Overview

Project summary and key metrics.

Dark

Underline

Overview
Activity
Settings

Overview

Project summary and key metrics.

Pills

Overview
Activity
Settings

Overview

Project summary and key metrics.

02

Live Demo

Variant

248

Commits

+12 this week

34

Pull requests

6 open

18

Issues

3 open

7

Contributors

+2 this month

03

Code Snippet

src/ui/Tabs.tsx
import { Tabs } from "@/ui/Tabs";

// Uncontrolled
<Tabs.Root defaultValue="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="activity">Activity</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
    <Tabs.Trigger value="disabled" disabled>Disabled</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">Overview content</Tabs.Content>
  <Tabs.Content value="activity">Activity content</Tabs.Content>
  <Tabs.Content value="settings">Settings content</Tabs.Content>
</Tabs.Root>

// Controlled
<Tabs.Root value={activeTab} onChange={setActiveTab}>
  ...
</Tabs.Root>

// Variants: "underline" (default) | "pills"
<Tabs.Root defaultValue="tab1" variant="pills">
  ...
</Tabs.Root>

Flat exports seperti TabsList, TabsTrigger, danTabsContent tetap didukung untuk migrasi bertahap.

04

Copy-Paste (Single File)

Snippet ini self-contained dan bisa langsung dipakai di project React/Next lain tanpa util tambahan.

Tabs.tsx
"use client";

import {
  createContext,
  useContext,
  useState,
  type ButtonHTMLAttributes,
  type HTMLAttributes,
  type ReactNode,
} from "react";

type ClassValue = string | false | null | undefined;
const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" ");

export type TabsVariant = "underline" | "pills";

export interface TabsProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  variant?: TabsVariant;
  children: ReactNode;
  className?: string;
}

interface TabsContextValue {
  active: string;
  setActive: (value: string) => void;
  variant: TabsVariant;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabsContext() {
  const context = useContext(TabsContext);
  if (!context) throw new Error("Tabs sub-components must be used inside <Tabs.Root>");
  return context;
}

function TabsRoot({
  value,
  defaultValue = "",
  onChange,
  variant = "underline",
  children,
  className,
}: TabsProps) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  const isControlled = value !== undefined;
  const active = isControlled ? value : internalValue;

  const setActive = (next: string) => {
    if (!isControlled) setInternalValue(next);
    onChange?.(next);
  };

  return (
    <TabsContext.Provider value={{ active, setActive, variant }}>
      <div className={cn("w-full", className)}>{children}</div>
    </TabsContext.Provider>
  );
}

function TabsList({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  const { variant } = useTabsContext();
  return (
    <div
      role="tablist"
      className={cn(
        "flex",
        variant === "underline" && "gap-0 border-b border-slate-200",
        variant === "pills" && "w-fit gap-1 rounded-xl bg-slate-100 p-1",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}

function TabsTrigger({
  value,
  className,
  children,
  disabled,
  ...props
}: Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value"> & { value: string }) {
  const { active, setActive, variant } = useTabsContext();
  const isActive = active === value;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      disabled={disabled}
      onClick={() => setActive(value)}
      className={cn(
        "rounded-sm text-sm font-medium transition-all outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/40",
        disabled && "pointer-events-none cursor-not-allowed opacity-40",
        variant === "underline" && [
          "-mb-px border-b-2 px-4 py-2.5",
          isActive ? "border-blue-600 text-blue-600" : "border-transparent text-slate-500 hover:text-slate-800",
        ],
        variant === "pills" && [
          "rounded-lg px-4 py-1.5",
          isActive ? "bg-white text-slate-900 shadow-xs" : "text-slate-500 hover:text-slate-700",
        ],
        className
      )}
      {...props}
    >
      {children}
    </button>
  );
}

function TabsContent({
  value,
  className,
  children,
  ...props
}: HTMLAttributes<HTMLDivElement> & { value: string }) {
  const { active } = useTabsContext();
  if (active !== value) return null;

  return (
    <div role="tabpanel" className={cn("mt-4", className)} {...props}>
      {children}
    </div>
  );
}

type TabsCompound = typeof TabsRoot & {
  Root: typeof TabsRoot;
  List: typeof TabsList;
  Trigger: typeof TabsTrigger;
  Content: typeof TabsContent;
};

export const Tabs = Object.assign(TabsRoot, {
  Root: TabsRoot,
  List: TabsList,
  Trigger: TabsTrigger,
  Content: TabsContent,
}) as TabsCompound;
05

Props

ComponentPropTypeDefaultDescription
Tabs.RootdefaultValuestring""Initially active tab (uncontrolled).
Tabs.RootvaluestringControlled active tab value.
Tabs.RootonChange(value: string) => voidCallback fired when the active tab changes.
Tabs.Rootvariant"underline" | "pills""underline"Visual style for the tab list.
Tabs.TriggervaluestringUnique identifier for this tab.
Tabs.TriggerdisabledbooleanfalsePrevents selection and reduces opacity.
Tabs.ContentvaluestringRenders only when this matches the active tab.
react-principles