Animated Tabs for React
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
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. |
Accessibility
- Uses correct ARIA roles: a tablist with aria-label Tabs, each button has role tab with aria-selected, and panels have role tabpanel with aria-labelledby linking back to their tab.
- Implements roving tabindex so only the selected tab is in the tab order (tabIndex 0) while the rest are tabIndex -1, matching the tabs interaction pattern.
- ArrowLeft and ArrowRight move between tabs with wraparound, call preventDefault, and programmatically move focus to the newly selected tab.
- Focus is clearly visible via focus-visible ring utilities (ring-2, ring-ring, ring-offset-2) rather than relying only on the active indicator.
- When showContent is set, each tab adds aria-controls pointing to its panel id so assistive tech can associate the tab with its content.
- Honours reduced-motion: both the tab buttons and the sliding indicator apply motion-reduce:transition-none to disable transitions for users who prefer reduced motion.
Performance
- The active-tab indicator animates only the transform property (translateX) which is GPU-composited and avoids layout or paint, keeping the slide cheap.
- Tab buttons use transition-colors for hover and selection state changes, a lightweight paint-only transition.
- Animations are gated by motion-reduce:transition-none, so reduced-motion users skip the transform and color transitions entirely.
- No requestAnimationFrame, IntersectionObserver, or will-change is used; the only timer is a single one-shot setTimeout(0) to move focus after navigation, so there are no recurring listeners or timers to clean up.
- Inactive panels stay mounted but hidden via the hidden class rather than being unmounted, trading a little extra DOM for instant tab switches.
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)}
/>;Related reading
- Smooth Operators — Animating the active-tab indicator
- When to Animate and When to Skip — Keeping tab transitions quick and useful
Last updated on Jun 19