Tabs
Accessible tabs with icon support, sliding indicator, and optional content panels. Keyboard navigable.
Component
Profile content goes here.
Documentation content goes here.
Settings content goes here.
"use client";
import type { LucideIcon } from "lucide-react";
import { useState } from "react";
interface Tab {
id: string;
label:
| string
| ((isSelected: boolean) => React.ReactNode);
icon: LucideIcon;
content?: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultSelected?: string;
selected?: string;
onTabChange?: (id: string) => void;
className?: string;
showContent?: boolean;
contentClassName?: string;
}
export function Tabs({
tabs,
defaultSelected,
selected,
onTabChange,
className = "",
showContent = false,
contentClassName = "",
}: TabsProps) {
const [internalSelected, setInternalSelected] = useState(
defaultSelected ?? tabs[0]?.id ?? "",
);
const isControlled = selected !== undefined;
const isSelected = isControlled
? selected
: internalSelected;
const handleTabChange = (id: string) => {
if (!isControlled) {
setInternalSelected(id);
}
onTabChange?.(id);
};
const handleKeyDown = (
e: React.KeyboardEvent,
id: string,
) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const currentIndex = tabs.findIndex(
(tab) => tab.id === id,
);
const nextIndex =
e.key === "ArrowLeft"
? (currentIndex - 1 + tabs.length) % tabs.length
: (currentIndex + 1) % tabs.length;
const nextId = tabs[nextIndex]?.id ?? "";
handleTabChange(nextId);
if (typeof window !== "undefined") {
setTimeout(() => {
document.getElementById(`tab-${nextId}`)?.focus();
}, 0);
}
}
};
const selectedIndex = tabs.findIndex(
(tab) => tab.id === isSelected,
);
const tabWidth = 100 / tabs.length;
const getGridClasses = () => {
if (tabs.length === 1) {
return "grid-cols-1";
}
if (tabs.length === 2) {
return "grid-cols-2";
}
if (tabs.length === 3) {
return "grid-cols-3";
}
if (tabs.length === 4) {
return "grid-cols-2 sm:grid-cols-4";
}
if (tabs.length === 5) {
return "grid-cols-3 sm:grid-cols-5";
}
if (tabs.length === 6) {
return "grid-cols-3 sm:grid-cols-6";
}
return "grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8";
};
const getTextClasses = () => {
if (tabs.length <= 2) {
return "text-sm";
}
if (tabs.length <= 4) {
return "text-xs sm:text-sm";
}
return "text-xs sm:text-sm md:text-xs lg:text-sm";
};
const getPaddingClasses = () => {
if (tabs.length <= 2) {
return "px-3 py-2";
}
if (tabs.length <= 4) {
return "px-2 py-2 sm:px-3";
}
return "px-2 py-2 sm:px-2 md:px-2 lg:px-3";
};
const getIconClasses = () => {
if (tabs.length <= 2) {
return "mr-2 h-4 w-4";
}
if (tabs.length <= 4) {
return "mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4";
}
return "mr-1 h-3 w-3 sm:mr-1 sm:h-3 sm:w-3 md:mr-1 md:h-3 md:w-3 lg:mr-2 lg:h-4 lg:w-4";
};
const getLabelDisplay = (label: string) => {
if (tabs.length <= 2) {
return (
<>
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">
{label.length > 12
? `${label.substring(0, 12)}...`
: label}
</span>
</>
);
}
if (tabs.length <= 4) {
return (
<>
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">
{label.length > 8
? `${label.substring(0, 8)}...`
: label}
</span>
</>
);
}
return (
<>
<span className="hidden md:inline">{label}</span>
<span className="md:hidden">
{label.length > 6
? `${label.substring(0, 6)}...`
: label}
</span>
</>
);
};
return (
<div className={className}>
<div className="relative mb-6">
<div
role="tablist"
aria-label="Tabs"
className={`grid w-full ${getGridClasses()} gap-1 rounded-lg bg-muted p-1`}
>
{tabs.map(({ id, label, icon: Icon }, index) => (
<button
key={`${id}-${index.toString()}`}
type="button"
role="tab"
aria-selected={isSelected === id}
{...(showContent
? { "aria-controls": `tabpanel-${id}` }
: {})}
id={`tab-${id}`}
tabIndex={isSelected === id ? 0 : -1}
className={`${getPaddingClasses()} ${getTextClasses()} z-10 inline-flex cursor-pointer items-center justify-center overflow-hidden whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none ${
isSelected === id
? "text-foreground"
: "text-muted-foreground"
}`}
onClick={() => handleTabChange(id)}
onKeyDown={(e) => handleKeyDown(e, id)}
>
<Icon className={getIconClasses()} />
{typeof label === "function"
? label(isSelected === id)
: getLabelDisplay(label)}
</button>
))}
<div
className="absolute inset-y-1 left-1 z-0 rounded-md border border-border bg-card shadow-sm transition-transform ease-out motion-reduce:transition-none"
style={{
transform: `translateX(${selectedIndex * 100}%)`,
width: `calc(${tabWidth}% - 2px)`,
}}
/>
</div>
</div>
{showContent && (
<div className={contentClassName}>
{tabs.map((tab) => (
<div
key={tab.id}
id={`tabpanel-${tab.id}`}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
className={`${isSelected === tab.id ? "block" : "hidden"} mt-6`}
>
{tab.content}
</div>
))}
</div>
)}
</div>
);
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/tabs.json"1. Install dependencies
pnpm add lucide-react2. Copy the component file
"use client";
import type { LucideIcon } from "lucide-react";
import { useState } from "react";
interface Tab {
id: string;
label:
| string
| ((isSelected: boolean) => React.ReactNode);
icon: LucideIcon;
content?: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultSelected?: string;
selected?: string;
onTabChange?: (id: string) => void;
className?: string;
showContent?: boolean;
contentClassName?: string;
}
export function Tabs({
tabs,
defaultSelected,
selected,
onTabChange,
className = "",
showContent = false,
contentClassName = "",
}: TabsProps) {
const [internalSelected, setInternalSelected] = useState(
defaultSelected ?? tabs[0]?.id ?? "",
);
const isControlled = selected !== undefined;
const isSelected = isControlled
? selected
: internalSelected;
const handleTabChange = (id: string) => {
if (!isControlled) {
setInternalSelected(id);
}
onTabChange?.(id);
};
const handleKeyDown = (
e: React.KeyboardEvent,
id: string,
) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const currentIndex = tabs.findIndex(
(tab) => tab.id === id,
);
const nextIndex =
e.key === "ArrowLeft"
? (currentIndex - 1 + tabs.length) % tabs.length
: (currentIndex + 1) % tabs.length;
const nextId = tabs[nextIndex]?.id ?? "";
handleTabChange(nextId);
if (typeof window !== "undefined") {
setTimeout(() => {
document.getElementById(`tab-${nextId}`)?.focus();
}, 0);
}
}
};
const selectedIndex = tabs.findIndex(
(tab) => tab.id === isSelected,
);
const tabWidth = 100 / tabs.length;
const getGridClasses = () => {
if (tabs.length === 1) {
return "grid-cols-1";
}
if (tabs.length === 2) {
return "grid-cols-2";
}
if (tabs.length === 3) {
return "grid-cols-3";
}
if (tabs.length === 4) {
return "grid-cols-2 sm:grid-cols-4";
}
if (tabs.length === 5) {
return "grid-cols-3 sm:grid-cols-5";
}
if (tabs.length === 6) {
return "grid-cols-3 sm:grid-cols-6";
}
return "grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8";
};
const getTextClasses = () => {
if (tabs.length <= 2) {
return "text-sm";
}
if (tabs.length <= 4) {
return "text-xs sm:text-sm";
}
return "text-xs sm:text-sm md:text-xs lg:text-sm";
};
const getPaddingClasses = () => {
if (tabs.length <= 2) {
return "px-3 py-2";
}
if (tabs.length <= 4) {
return "px-2 py-2 sm:px-3";
}
return "px-2 py-2 sm:px-2 md:px-2 lg:px-3";
};
const getIconClasses = () => {
if (tabs.length <= 2) {
return "mr-2 h-4 w-4";
}
if (tabs.length <= 4) {
return "mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4";
}
return "mr-1 h-3 w-3 sm:mr-1 sm:h-3 sm:w-3 md:mr-1 md:h-3 md:w-3 lg:mr-2 lg:h-4 lg:w-4";
};
const getLabelDisplay = (label: string) => {
if (tabs.length <= 2) {
return (
<>
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">
{label.length > 12
? `${label.substring(0, 12)}...`
: label}
</span>
</>
);
}
if (tabs.length <= 4) {
return (
<>
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">
{label.length > 8
? `${label.substring(0, 8)}...`
: label}
</span>
</>
);
}
return (
<>
<span className="hidden md:inline">{label}</span>
<span className="md:hidden">
{label.length > 6
? `${label.substring(0, 6)}...`
: label}
</span>
</>
);
};
return (
<div className={className}>
<div className="relative mb-6">
<div
role="tablist"
aria-label="Tabs"
className={`grid w-full ${getGridClasses()} gap-1 rounded-lg bg-muted p-1`}
>
{tabs.map(({ id, label, icon: Icon }, index) => (
<button
key={`${id}-${index.toString()}`}
type="button"
role="tab"
aria-selected={isSelected === id}
{...(showContent
? { "aria-controls": `tabpanel-${id}` }
: {})}
id={`tab-${id}`}
tabIndex={isSelected === id ? 0 : -1}
className={`${getPaddingClasses()} ${getTextClasses()} z-10 inline-flex cursor-pointer items-center justify-center overflow-hidden whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none ${
isSelected === id
? "text-foreground"
: "text-muted-foreground"
}`}
onClick={() => handleTabChange(id)}
onKeyDown={(e) => handleKeyDown(e, id)}
>
<Icon className={getIconClasses()} />
{typeof label === "function"
? label(isSelected === id)
: getLabelDisplay(label)}
</button>
))}
<div
className="absolute inset-y-1 left-1 z-0 rounded-md border border-border bg-card shadow-sm transition-transform ease-out motion-reduce:transition-none"
style={{
transform: `translateX(${selectedIndex * 100}%)`,
width: `calc(${tabWidth}% - 2px)`,
}}
/>
</div>
</div>
{showContent && (
<div className={contentClassName}>
{tabs.map((tab) => (
<div
key={tab.id}
id={`tabpanel-${tab.id}`}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
className={`${isSelected === tab.id ? "block" : "hidden"} mt-6`}
>
{tab.content}
</div>
))}
</div>
)}
</div>
);
}3. Import and use
import { Tabs } from "@/components/tabs";
import { User } from "lucide-react";
<Tabs
tabs={[{ id: "tab1", label: "Tab 1", icon: User }]}
/>;Usage
Import
Add the Tabs import.
import { Tabs } from "@/components/tabs";Use
Pass tabs array with id, label, icon.
<Tabs
tabs={[{ id: "tab1", label: "Tab 1", icon: User }]}
/>;Guidelines
- Each tab requires id, label, and icon (LucideIcon from lucide-react).
- showContent=true renders tab.content in panels below the tab bar.
- Use onTabChange to react to tab selection (e.g. scroll, fetch data).
- Keyboard: ArrowLeft and ArrowRight navigate between tabs.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | Tab[] | — | Array of tab objects with id, label, icon, and optional content. |
| defaultSelected | string | — | Initially selected tab id. |
| selected | string | — | Controlled selected tab id. |
| onTabChange | (id: string) => void | — | Callback when tab changes. |
| className | string | "" | Additional CSS classes for the container. |
| showContent | boolean | false | Whether to render tab content panels. |
| contentClassName | string | "" | Classes for the content container. |
Examples
With content
Three tabs with content panels.
Profile content goes here.
Documentation content goes here.
Settings content goes here.
import { Tabs } from "@/components/tabs";
import { FileText, Settings, User } from "lucide-react";
<Tabs
tabs={[
{
id: "profile",
label: "Profile",
icon: User,
content: <p>Profile content</p>,
},
{
id: "docs",
label: "Docs",
icon: FileText,
content: <p>Docs content</p>,
},
{
id: "settings",
label: "Settings",
icon: Settings,
content: <p>Settings content</p>,
},
]}
defaultSelected="profile"
showContent={true}
/>;Navigation only
Tabs without content, use onTabChange for navigation.
Profile content goes here.
Documentation content goes here.
Settings content goes here.
import { Tabs } from "@/components/tabs";
import { Code2, Layers } from "lucide-react";
<Tabs
tabs={[
{ id: "code", label: "Code", icon: Code2 },
{ id: "preview", label: "Preview", icon: Layers },
]}
defaultSelected="code"
onTabChange={(id) => console.log(id)}
/>;Last updated on Mar 10