Animated Tabs
Segmented tabs with a pill that slides between items using a shared layout animation.
- layoutId
- spring
- a11y: tabs pattern
A shared layoutId lets the pill travel between tabs instead of blinking.
Install
npx shadcn@latest add https://tcosta.dev/r/animated-tabs.jsonOr copy the files below into components/animated-tabs/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion.
Source
tabs.tsx
'use client';
import { useId, type ReactNode } from 'react';
import { TabsContext } from './tabs-context';
type TabsProps = {
value: string;
onValueChange: (value: string) => void;
className?: string;
children: ReactNode;
};
export function Tabs({ value, onValueChange, className, children }: TabsProps) {
const id = useId();
return (
<TabsContext value={{ id, value, onValueChange }}>
<div className={className}>{children}</div>
</TabsContext>
);
}
tabs-list.tsx
'use client';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { useTabs } from './tabs-context';
import { useTabsKeyboard } from './use-tabs-keyboard';
type TabsListProps = {
label: string;
className?: string;
children: ReactNode;
};
export function TabsList({ label, className, children }: TabsListProps) {
const { onValueChange } = useTabs();
const onKeyDown = useTabsKeyboard(onValueChange);
return (
<div
role="tablist"
aria-label={label}
onKeyDown={onKeyDown}
className={cn('inline-flex items-center gap-1 rounded-full bg-muted p-1', className)}
>
{children}
</div>
);
}
tabs-trigger.tsx
'use client';
import { motion } from 'motion/react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { contentId, triggerId, useTabs } from './tabs-context';
type TabsTriggerProps = {
value: string;
className?: string;
children: ReactNode;
};
export function TabsTrigger({ value, className, children }: TabsTriggerProps) {
const tabs = useTabs();
const isSelected = tabs.value === value;
return (
<button
type="button"
role="tab"
id={triggerId(tabs.id, value)}
data-value={value}
aria-selected={isSelected}
aria-controls={contentId(tabs.id, value)}
tabIndex={isSelected ? 0 : -1}
onClick={() => tabs.onValueChange(value)}
className={cn(
'relative rounded-full px-4 py-1.5 text-sm font-medium text-muted-foreground transition-colors duration-200 outline-none',
'hover:text-foreground aria-selected:text-foreground',
'focus-visible:ring-[3px] focus-visible:ring-ring/50',
className,
)}
>
{isSelected && (
<motion.span
layoutId={`${tabs.id}-indicator`}
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
className="absolute inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
/>
)}
<span className="relative">{children}</span>
</button>
);
}
tabs-content.tsx
'use client';
import { motion } from 'motion/react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { contentId, triggerId, useTabs } from './tabs-context';
type TabsContentProps = {
value: string;
className?: string;
children: ReactNode;
};
export function TabsContent({ value, className, children }: TabsContentProps) {
const tabs = useTabs();
if (tabs.value !== value) return null;
return (
<motion.div
role="tabpanel"
id={contentId(tabs.id, value)}
aria-labelledby={triggerId(tabs.id, value)}
tabIndex={0}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className={cn('outline-none', className)}
>
{children}
</motion.div>
);
}
tabs-context.ts
'use client';
import { createContext, useContext } from 'react';
type TabsContextValue = {
id: string;
value: string;
onValueChange: (value: string) => void;
};
export const TabsContext = createContext<TabsContextValue | null>(null);
export function useTabs() {
const context = useContext(TabsContext);
if (!context) throw new Error('Tabs parts must be rendered inside <Tabs>.');
return context;
}
export const triggerId = (id: string, value: string) => `${id}-tab-${value}`;
export const contentId = (id: string, value: string) => `${id}-panel-${value}`;
use-tabs-keyboard.ts
import type { KeyboardEvent } from 'react';
function nextIndex(key: string, current: number, count: number) {
switch (key) {
case 'ArrowRight':
return (current + 1) % count;
case 'ArrowLeft':
return (current - 1 + count) % count;
case 'Home':
return 0;
case 'End':
return count - 1;
}
}
/** WAI-ARIA tabs keyboard pattern: ←/→ move (wrapping), Home/End jump. */
export function useTabsKeyboard(onSelect: (value: string) => void) {
return (event: KeyboardEvent<HTMLElement>) => {
const tabs = [...event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')];
const next = nextIndex(event.key, tabs.indexOf(event.target as HTMLElement), tabs.length);
if (next === undefined) return;
event.preventDefault();
tabs[next].focus();
onSelect(tabs[next].dataset.value!);
};
}
index.ts
export { Tabs } from './tabs';
export { TabsContent } from './tabs-content';
export { TabsList } from './tabs-list';
export { TabsTrigger } from './tabs-trigger';