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
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Tabs.Root | defaultValue | string | "" | Initially active tab (uncontrolled). |
Tabs.Root | value | string | — | Controlled active tab value. |
Tabs.Root | onChange | (value: string) => void | — | Callback fired when the active tab changes. |
Tabs.Root | 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. |