GitHub

Tabs

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

AccessibleDark ModeControlledUncontrolled2 VariantsCompound

Install

$npx react-principles add tabs
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.

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 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>

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

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

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
import {
  createContext,
  useContext,
  useState,
  ButtonHTMLAttributes,
  HTMLAttributes,
  ReactNode,
} from "react";
import { cn } from "@/lib/utils";

// ─── Types ────────────────────────────────────────────────────────────────────

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

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

export interface TabsListProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
}

export interface TabsTriggerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
  value: string;
  children: ReactNode;
}

export interface TabsContentProps extends HTMLAttributes<HTMLDivElement> {
  value: string;
  children: ReactNode;
}

// ─── Context ──────────────────────────────────────────────────────────────────

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

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

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

// ─── Components ───────────────────────────────────────────────────────────────

export function Tabs({
  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>
  );
}

Tabs.List = function TabsList({ children, className, ...props }: TabsListProps) {
  const { variant } = useTabsContext();

  return (
    <div
      role="tablist"
      className={cn(
        "flex",
        variant === "underline" && "border-b border-slate-200 dark:border-[#1f2937] gap-0",
        variant === "pills" && "gap-1 p-1 rounded-xl bg-slate-100 dark:bg-[#161b22] w-fit",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}

Tabs.Trigger = function TabsTrigger({ value, children, disabled, className, ...props }: TabsTriggerProps) {
  const { active, setActive, variant } = useTabsContext();
  const isActive = active === value;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      disabled={disabled}
      onClick={() => setActive(value)}
      className={cn(
        "text-sm font-medium transition-all outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40 rounded-sm",
        disabled && "opacity-40 cursor-not-allowed pointer-events-none",

        // underline variant
        variant === "underline" && [
          "px-4 py-2.5 -mb-px border-b-2",
          isActive
            ? "border-primary text-primary"
            : "border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:border-slate-300 dark:hover:border-slate-600",
        ],

        // pills variant
        variant === "pills" && [
          "px-4 py-1.5 rounded-lg",
          isActive
            ? "bg-white dark:bg-[#0d1117] text-slate-900 dark:text-white shadow-xs"
            : "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200",
        ],

        className
      )}
      {...props}
    >
      {children}
    </button>
  );
}

Tabs.Content = function TabsContent({ value, children, className, ...props }: TabsContentProps) {
  const { active } = useTabsContext();

  if (active !== value) return null;

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

Props

ComponentPropTypeDefaultDescription
TabsdefaultValuestring""Initially active tab (uncontrolled).
TabsvaluestringControlled active tab value.
TabsonChange(value: string) => voidCallback fired when the active tab changes.
Tabsvariant"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