Navigation
Animated Tabs
A tab bar with a spring-loaded pill that slides between the active item.
A summary of everything happening in the workspace.
import { AnimatedTabs } from "@/components/ui/animated-tabs";
import { useState } from "react";
const tabs = [
{ id: "overview", label: "Overview" },
{ id: "activity", label: "Activity" },
{ id: "billing", label: "Billing" },
];
const copy: Record<string, string> = {
overview: "A summary of everything happening in the workspace.",
activity: "Every deploy, comment, and revert, newest first.",
billing: "Seats, usage, and invoices for the current period.",
};
export default function AnimatedTabsDemo() {
const [active, setActive] = useState("overview");
return (
<div className="flex flex-col items-center gap-5">
<AnimatedTabs tabs={tabs} onChange={setActive} />
<p className="text-sm text-muted">{copy[active]}</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/animated-tabs.tsx "use client"; import { motion } from "motion/react"; import { useId, useState } from "react"; import { cn } from "@/lib/utils"; type Tab = { id: string; label: string }; type AnimatedTabsProps = { tabs: Tab[]; className?: string; /** Uncontrolled starting tab. Defaults to the first one. */ defaultTab?: string; onChange?: (id: string) => void; }; export function AnimatedTabs({ tabs, className, defaultTab, onChange, }: AnimatedTabsProps) { const [active, setActive] = useState(defaultTab ?? tabs[0]?.id); // Keeps the sliding pill scoped to this instance when several are on a page. const layoutId = useId(); return ( <div role="tablist" className={cn( "inline-flex items-center gap-1 rounded-full border border-line bg-elevated p-1", className, )} > {tabs.map((tab) => { const isActive = tab.id === active; return ( <button key={tab.id} type="button" role="tab" aria-selected={isActive} onClick={() => { setActive(tab.id); onChange?.(tab.id); }} className={cn( "relative rounded-full px-4 py-1.5 text-sm font-medium transition-colors", "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent", isActive ? "text-accent-ink" : "text-muted hover:text-ink", )} > {isActive ? ( <motion.span layoutId={layoutId} transition={{ type: "spring", stiffness: 380, damping: 32 }} className="absolute inset-0 rounded-full bg-accent" /> ) : null} <span className="relative">{tab.label}</span> </button> ); })} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | { id: string; label: string }[] | — | Tabs to render, in order. |
| defaultTab | string | tabs[0].id | Uncontrolled starting tab. |
| onChange | (id: string) => void | — | Fires with the id of the newly selected tab. |
| className | string | — | Merged onto the tab list. |