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 tabs01
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 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
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Tabs | defaultValue | string | "" | Initially active tab (uncontrolled). |
Tabs | value | string | — | Controlled active tab value. |
Tabs | onChange | (value: string) => void | — | Callback fired when the active tab changes. |
Tabs | variant | "underline" | "pills" | "underline" | Visual style for the tab list. |
Tabs.Trigger | value | string | — | Unique identifier for this tab. |
Tabs.Trigger | disabled | boolean | false | Prevents selection and reduces opacity. |
Tabs.Content | value | string | — | Renders only when this matches the active tab. |