Componentnavigation1.48 KB gzipZero dependencies
Tabs
Segmented panel switch with roving keyboard focus.
Preview
Live Preview
Preview panel content.
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add tabsOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
<Tabs defaultValue="one">
<TabsList>
<TabsTrigger value="one">One</TabsTrigger>
<TabsTrigger value="two">Two</TabsTrigger>
</TabsList>
<TabsContent value="one">First.</TabsContent>
<TabsContent value="two">Second.</TabsContent>
</Tabs>Usage guidance
Use Tabs when a ticket stub surface needs Segmented panel switch with roving keyboard focus. Keep props minimal and prefer composition with sibling stubs.
Avoid Tabs for long form flows or dense data grids where a dedicated layout block fits better.
Source code
tabs.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
type TabsContextValue = { value: string; setValue: (v: string) => void; baseId: string; animated: boolean };
const TabsCtx = React.createContext<TabsContextValue | null>(null);
export type TabsProps = React.HTMLAttributes<HTMLDivElement> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Dual-mode: false renders instantly with no motion. Default true. */
animated?: boolean;
};
export function Tabs({ value, defaultValue, onValueChange, animated = true, className, children, ...props }: TabsProps) {
const [internal, setInternal] = React.useState(defaultValue ?? "");
const current = value ?? internal;
const setValue = React.useCallback(
(next: string) => {
if (value === undefined) setInternal(next);
onValueChange?.(next);
},
[value, onValueChange]
);
return (
<TabsCtx.Provider value={{ value: current, setValue, baseId: React.useId(), animated }}>
<div className={className} {...props}>
{children}
</div>
</TabsCtx.Provider>
);
}
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=tab]:not(:disabled)"));
if (tabs.length === 0) return;
const currentIndex = tabs.findIndex((tab) => tab === document.activeElement);
let nextIndex = currentIndex;
if (event.key === "ArrowRight" || event.key === "ArrowDown") nextIndex = (currentIndex + 1) % tabs.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = tabs.length - 1;
else return;
event.preventDefault();
tabs[nextIndex]!.focus();
tabs[nextIndex]!.click();
};
return (
<div
role="tablist"
onKeyDown={handleKeyDown}
className={cn("inline-flex h-10 items-center justify-center gap-1 rounded-md border border-dashed border-foreground/50 bg-transparent p-1 text-muted-foreground", className)}
{...props}
/>
);
}
export type TabsTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
value: string;
/** Dual-mode override: false renders instantly with no motion. Defaults to Tabs animated. */
animated?: boolean;
};
export function TabsTrigger({ value, animated: animatedProp, className, onClick, ...props }: TabsTriggerProps) {
const ctx = React.useContext(TabsCtx);
const isActive = ctx?.value === value;
const animated = animatedProp ?? ctx?.animated ?? true;
return (
<button
type="button"
role="tab"
id={`${ctx?.baseId}-tab-${value}`}
aria-selected={isActive}
aria-controls={`${ctx?.baseId}-panel-${value}`}
tabIndex={isActive ? 0 : -1}
data-state={isActive ? "active" : "inactive"}
onClick={(event) => {
ctx?.setValue(value);
onClick?.(event);
}}
className={cn(
"relative inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-[4px] px-3 py-1 font-mono text-xs uppercase tracking-[0.12em] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
animated ? "transition-colors motion-reduce:transition-none" : "transition-none",
isActive && "bg-primary text-primary-foreground shadow-sm",
className
)}
{...props}
/>
);
}
export type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & { value: string };
export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
const ctx = React.useContext(TabsCtx);
if (ctx?.value !== value) return null;
return (
<div
role="tabpanel"
id={`${ctx.baseId}-panel-${value}`}
aria-labelledby={`${ctx.baseId}-tab-${value}`}
tabIndex={0}
className={cn("mt-3 text-sm focus-visible:outline-none", className)}
{...props}
>
{children}
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| defaultValue | string | Initially active tab value. |
| value | string | Controlled active tab value. |
| onValueChange | (value: string) => void | Called when active tab changes. |