Overview
A sliding pill follows your selection.
Usage
import { AnimatedTabs } from "@/components/motionkit/animated-tabs";
<AnimatedTabs
tabs={[
{ id: "overview", label: "Overview", content: <p>Overview</p> },
{ id: "install", label: "Install", content: <p>Install</p> },
{ id: "props", label: "Props", content: <p>Props</p> },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Indicator slide
Tab changes
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/animated-tabs.tsx.
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type Tab = {
id: string;
label: string;
content: ReactNode;
};
type AnimatedTabsProps = {
tabs: Tab[];
defaultTab?: string;
className?: string;
};
export function AnimatedTabs({ tabs, defaultTab, className = "" }: AnimatedTabsProps) {
const [active, setActive] = useState(defaultTab ?? tabs[0]?.id);
const layoutId = useId();
const reduceMotion = useReducedMotion();
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const current = tabs.find((tab) => tab.id === active);
// WAI-ARIA tabs pattern: arrows move between tabs, Home/End jump to the ends
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const index = tabs.findIndex((tab) => tab.id === active);
const next = {
ArrowRight: (index + 1) % tabs.length,
ArrowLeft: (index - 1 + tabs.length) % tabs.length,
Home: 0,
End: tabs.length - 1,
}[event.key];
if (next === undefined) return;
event.preventDefault();
setActive(tabs[next].id);
buttons.current[next]?.focus();
}
return (
<div className={`w-full ${className}`}>
<div
role="tablist"
onKeyDown={handleKeyDown}
className="inline-flex flex-wrap gap-1 rounded-xl border border-white/10 bg-white/[0.03] p-1"
>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(element) => {
buttons.current[index] = element;
}}
role="tab"
tabIndex={active === tab.id ? 0 : -1}
id={`${layoutId}-tab-${tab.id}`}
aria-selected={active === tab.id}
aria-controls={`${layoutId}-panel`}
onClick={() => setActive(tab.id)}
className={`relative rounded-lg px-3.5 py-2 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-[#5FAEB4] ${
active === tab.id ? "text-white" : "text-[#94A9A7] hover:text-[#DCE3E2]"
}`}
>
{active === tab.id && (
<motion.span
layoutId={layoutId}
className="absolute inset-0 rounded-lg bg-[#0E7C86] shadow-[0_4px_20px_-6px_rgb(14_124_134/0.6)]"
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.45 }}
/>
)}
<span className="relative">{tab.label}</span>
</button>
))}
</div>
<div
role="tabpanel"
id={`${layoutId}-panel`}
aria-labelledby={`${layoutId}-tab-${active}`}
className="relative mt-4"
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(4px)" }}
transition={{ duration: 0.2 }}
>
{current?.content}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | { id, label, content }[] | — | The tabs to render. |
| defaultTab | string | — | Id of the initially selected tab. |
Hover Nav
A pill navbar where a soft highlight glides to whatever you point at or tab to.
Magnetic Dock
A floating icon bar that magnifies and lifts around the pointer, with tooltips and a gliding active indicator.
Command Menu
A ⌘K palette with live filtering, grouped results and a highlight that glides between them.