Tabs
A tab navigation component with smooth animations and keyboard support.
import {
CreditCardIcon,
GearIcon,
UsersIcon,
} from '@phosphor-icons/react/dist/ssr';
import {
Tabs,
TabsItem,
TabsItems,
TabsPanel,
TabsPanels,
} from '@/components/tabs';
export default function TabsPreview() {
return (
<Tabs>
<TabsItems>
<TabsItem>
<UsersIcon />
<span>Users</span>
</TabsItem>
<TabsItem>
<CreditCardIcon />
<span>Billing</span>
</TabsItem>
<TabsItem>
<GearIcon />
<span>Settings</span>
</TabsItem>
</TabsItems>
<TabsPanels>
<TabsPanel>Panel 1</TabsPanel>
<TabsPanel>Panel 2</TabsPanel>
<TabsPanel>Panel 3</TabsPanel>
</TabsPanels>
</Tabs>
);
} Dependencies
Source Code
'use client';
import { motion } from 'motion/react';
import {
Children,
createContext,
use,
useCallback,
useId,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';
interface TabsContextValue {
id: string;
tabs: string[];
selectedTab: string | undefined;
setSelectedTab: (id: string) => void;
next: () => void;
previous: () => void;
orientation: 'horizontal' | 'vertical';
registerTab: (id: string) => () => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
const useTabsContext = () => {
const context = use(TabsContext);
if (!context)
throw new Error('TabsContext must be used within a Tabs component');
return context;
};
interface TabsProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange'> {
defaultIndex?: number;
selectedIndex?: number;
onChange?: (index: number) => void;
orientation?: TabsContextValue['orientation'];
children: React.ReactNode;
}
const Tabs = ({
defaultIndex,
selectedIndex: selectedIndexProp,
onChange: onChangeProp,
orientation = 'horizontal',
children,
...props
}: TabsProps) => {
const id = useId();
const [internalSelectedIndex, setInternalSelectedIndex] = useState(
defaultIndex ?? 0
);
const [tabs, setTabs] = useState<string[]>([]);
const selectedIndex = selectedIndexProp ?? internalSelectedIndex;
const setSelectedIndex = useCallback(
(index: number) => {
setInternalSelectedIndex(index);
onChangeProp?.(index);
// focus on the selected tab when changing index
const itemId = getItemId(tabs[index]);
if (itemId) {
const selectedTab = document.getElementById(itemId);
selectedTab?.focus();
}
},
[onChangeProp, tabs]
);
const next = useCallback(() => {
setSelectedIndex((selectedIndex + 1) % tabs.length);
}, [tabs.length, selectedIndex, setSelectedIndex]);
const previous = useCallback(() => {
setSelectedIndex(selectedIndex <= 0 ? tabs.length - 1 : selectedIndex - 1);
}, [tabs.length, selectedIndex, setSelectedIndex]);
const registerTab = useCallback((id: string) => {
setTabs((prev) => [...prev, id]);
return () => {
setTabs((prev) => prev.filter((prevId) => prevId !== id));
};
}, []);
const selectedTab = useMemo(() => tabs[selectedIndex], [tabs, selectedIndex]);
const setSelectedTab = useCallback(
(id: string) => {
setSelectedIndex(tabs.indexOf(id));
},
[tabs, setSelectedIndex]
);
const ctx = useMemo(
() => ({
id,
tabs,
selectedTab,
setSelectedTab,
next,
previous,
orientation,
registerTab,
}),
[
id,
tabs,
selectedTab,
setSelectedTab,
next,
previous,
orientation,
registerTab,
]
);
return (
<TabsContext value={ctx}>
<div {...props}>{children}</div>
</TabsContext>
);
};
interface TabsItemsProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'role'> {
children: React.ReactNode;
}
const TabsItems = ({ children, className, ...props }: TabsItemsProps) => {
const { orientation } = useTabsContext();
return (
<div
role="tablist"
aria-orientation={orientation}
className={cn(
'flex gap-1.5',
orientation === 'horizontal'
? 'items-center pb-4'
: 'flex-col items-start pr-4',
className
)}
{...props}
>
{children}
</div>
);
};
interface TabsItemProps
extends Omit<
React.ComponentPropsWithRef<'button'>,
'id' | 'type' | 'disabled'
> {
children: React.ReactNode;
asChild?: boolean;
}
const getItemId = (id: string | undefined) => (id ? `tab${id}` : undefined);
const TabsItem = ({
children,
asChild,
onClick,
onKeyDown,
className,
...props
}: TabsItemProps) => {
const id = useId();
const {
id: tabsId,
selectedTab,
setSelectedTab,
registerTab,
orientation,
next,
previous,
} = useTabsContext();
const Comp = asChild ? Slot : 'button';
useLayoutEffect(() => {
registerTab(id);
}, [id, registerTab]);
const isSelected = selectedTab === id;
const handleKeyboardNavigation = (
e: React.KeyboardEvent<HTMLButtonElement>
) => {
if (e.key === 'Enter' || e.key === ' ') {
setSelectedTab(id);
}
const keyOrientationMap = {
horizontal: {
next: 'ArrowRight',
prev: 'ArrowLeft',
},
vertical: {
next: 'ArrowDown',
prev: 'ArrowUp',
},
};
if (keyOrientationMap[orientation].next === e.key) {
e.preventDefault();
next();
}
if (keyOrientationMap[orientation].prev === e.key) {
e.preventDefault();
previous();
}
};
return (
<Comp
id={getItemId(id)}
type="button"
className={cn(
'relative flex cursor-pointer items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-foreground/50 outline-none ring-ring transition hover:text-foreground focus-visible:ring-4 data-selected:text-foreground',
'[&>*:not([data-tab-indicator])]:z-10',
className
)}
role="tab"
aria-controls={getPanelId(id)}
aria-selected={isSelected || undefined}
data-selected={isSelected || undefined}
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) {
setSelectedTab(id);
}
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (!e.defaultPrevented) {
handleKeyboardNavigation(e);
}
}}
{...props}
>
{typeof children === 'string' ? <span>{children}</span> : children}
{isSelected && (
<motion.span
data-tab-indicator="true"
layoutId={tabsId}
aria-hidden="true"
className="absolute inset-0 z-0 rounded-xl bg-background-secondary"
transition={{ type: 'spring', duration: 0.3, bounce: 0.2 }}
/>
)}
</Comp>
);
};
interface TabsPanelsProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'role'> {
children: React.ReactNode;
}
const TabsPanels = ({ children, className, ...props }: TabsPanelsProps) => {
const { tabs } = useTabsContext();
return (
<div
className={cn('ring-ring transition has-focus-visible:ring-4', className)}
{...props}
>
{Children.map(children, (child, index) => (
<PanelIdContext value={tabs[index]}>{child}</PanelIdContext>
))}
</div>
);
};
interface TabsPanelProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'id'> {
children: React.ReactNode;
asChild?: boolean;
}
const PanelIdContext = createContext<string | undefined>(undefined);
const getPanelId = (id: string | undefined) =>
id ? `tab-panel${id}` : undefined;
const TabsPanel = ({
children,
asChild,
className,
...props
}: TabsPanelProps) => {
const id = use(PanelIdContext);
const { selectedTab } = useTabsContext();
const Comp = asChild ? Slot : 'div';
const isSelected = selectedTab === id;
return (
<Comp
id={getPanelId(id)}
role="tabpanel"
className={cn('outline-none', className)}
aria-labelledby={getItemId(id)}
aria-hidden={!isSelected}
data-selected={isSelected || undefined}
tabIndex={isSelected ? 0 : -1}
{...props}
>
{isSelected ? children : null}
</Comp>
);
};
export { Tabs, TabsItem, TabsItems, TabsPanel, TabsPanels }; Features
- Smooth Animations: Animated transitions between tabs using Framer Motion
- Keyboard Navigation: Full keyboard support for tab navigation
- Orientation Support: Both horizontal and vertical layouts
- Controlled & Uncontrolled: Flexible state management options
- ARIA Support: Full accessibility implementation
- Custom Styling: Extensible styling through className props
Anatomy
<Tabs>
<TabsItems>
<TabsItem />
</TabsItems>
<TabsPanels>
<TabsPanel />
</TabsPanels>
</Tabs>
API Reference
Tabs
The root container component that manages the state and behavior of the tabs.
| Prop | Default | Type | Description |
|---|---|---|---|
defaultIndex | - | number | The index of the tab that should be active by default. |
selectedIndex | - | number | The controlled index of the active tab. |
onChange | - | (index: number) => void | Callback fired when the active tab changes. |
orientation | horizontal | "horizontal" | "vertical" | The orientation of the tabs. |
TabsItems
The container for tab buttons. Provides proper ARIA attributes and keyboard navigation.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
TabsItem
The individual tab button component.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
disabled | false | boolean | Whether the tab is disabled. |
TabsPanels
The container for tab panels. Manages the visibility of panels based on the selected tab.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
TabsPanel
The individual tab panel component.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
Accessibility
The Tabs component follows the WAI-ARIA Tabs Pattern. It implements all the required ARIA attributes and keyboard interactions.
Examples
Basic Usage
A basic example of tabs with icons and labels.
import {
CreditCardIcon,
GearIcon,
UsersIcon,
} from '@phosphor-icons/react/dist/ssr';
import {
Tabs,
TabsItem,
TabsItems,
TabsPanel,
TabsPanels,
} from '@/components/tabs';
export default function TabsPreview() {
return (
<Tabs>
<TabsItems>
<TabsItem>
<UsersIcon />
<span>Users</span>
</TabsItem>
<TabsItem>
<CreditCardIcon />
<span>Billing</span>
</TabsItem>
<TabsItem>
<GearIcon />
<span>Settings</span>
</TabsItem>
</TabsItems>
<TabsPanels>
<TabsPanel>Panel 1</TabsPanel>
<TabsPanel>Panel 2</TabsPanel>
<TabsPanel>Panel 3</TabsPanel>
</TabsPanels>
</Tabs>
);
} Vertical Orientation
Tabs can be oriented vertically using the orientation prop.
Users Panel
Manage your users here.
Billing Panel
Manage your billing information.
Settings Panel
Configure your application settings.
import {
CreditCardIcon,
GearIcon,
UsersIcon,
} from '@phosphor-icons/react/dist/ssr';
import {
Tabs,
TabsItem,
TabsItems,
TabsPanel,
TabsPanels,
} from '@/components/tabs';
export default function TabsVerticalPreview() {
return (
<Tabs orientation="vertical" className="flex gap-4">
<TabsItems className="w-60">
<TabsItem className="w-full justify-start">
<UsersIcon />
<span>Users</span>
</TabsItem>
<TabsItem className="w-full justify-start">
<CreditCardIcon />
<span>Billing</span>
</TabsItem>
<TabsItem className="w-full justify-start">
<GearIcon />
<span>Settings</span>
</TabsItem>
</TabsItems>
<TabsPanels className="w-90">
<TabsPanel>
<h3 className="font-medium text-lg">Users Panel</h3>
<p className="text-foreground-secondary">Manage your users here.</p>
</TabsPanel>
<TabsPanel>
<h3 className="font-medium text-lg">Billing Panel</h3>
<p className="text-foreground-secondary">
Manage your billing information.
</p>
</TabsPanel>
<TabsPanel>
<h3 className="font-medium text-lg">Settings Panel</h3>
<p className="text-foreground-secondary">
Configure your application settings.
</p>
</TabsPanel>
</TabsPanels>
</Tabs>
);
} Controlled Tabs
Example of controlled tabs with external state management.
'use client';
import { useState } from 'react';
import {
Tabs,
TabsItem,
TabsItems,
TabsPanel,
TabsPanels,
} from '@/components/tabs';
export default function TabsControlledPreview() {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-foreground-secondary text-sm">Current tab:</span>
<span className="font-medium">{selectedIndex}</span>
<button
type="button"
onClick={() => setSelectedIndex((prev) => (prev + 1) % 3)}
className="rounded-lg border px-3 py-1 text-sm"
>
Next tab
</button>
</div>
<Tabs selectedIndex={selectedIndex} onChange={setSelectedIndex}>
<TabsItems>
<TabsItem>Tab 1</TabsItem>
<TabsItem>Tab 2</TabsItem>
<TabsItem>Tab 3</TabsItem>
</TabsItems>
<TabsPanels>
<TabsPanel>Panel 1</TabsPanel>
<TabsPanel>Panel 2</TabsPanel>
<TabsPanel>Panel 3</TabsPanel>
</TabsPanels>
</Tabs>
</div>
);
} Best Practices
-
Content Organization:
- Use meaningful tab labels that describe their content
- Keep tab labels concise
- Order tabs in a logical sequence
-
Performance:
- Lazy load tab content when possible
- Consider using dynamic imports for heavy content
- Use motion animations sparingly on low-end devices
-
Accessibility:
- Ensure tab labels are descriptive
- Maintain a logical tab order
- Consider users who navigate with keyboard only
-
Mobile Considerations:
- Ensure touch targets are large enough
- Consider scrollable tabs for many items
- Test with different screen sizes
Previous
Switch
Next
Textarea