STUDY / 002 · Navigation
Animated tabs
A shared background that follows your focus.
Preview
A place for the bigger picture.
Live preview · Go ahead, interact with it
Code
"use client";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
const tabs = ["Overview", "Activity", "Settings"];
export default function AnimatedTabs({ className }: { className?: string }) {
const [active, setActive] = useState("Overview");
const id = useId();
const reduced = useReducedMotion();
return (
<LayoutGroup id={id}>
<div className={cn("w-full max-w-72 text-center", className)}>
<div
className="flex rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 p-1"
role="tablist"
aria-label="Example navigation"
>
{tabs.map((tab, index) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={active === tab}
aria-controls={`${id}-panel`}
id={`${id}-${tab}`}
tabIndex={active === tab ? 0 : -1}
onKeyDown={(event) => {
let next = index;
if (event.key === "ArrowRight")
next = (index + 1) % tabs.length;
else if (event.key === "ArrowLeft")
next = (index + tabs.length - 1) % tabs.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = tabs.length - 1;
else return;
event.preventDefault();
setActive(tabs[next]);
document.getElementById(`${id}-${tabs[next]}`)?.focus();
}}
onClick={() => setActive(tab)}
className="relative flex-1 rounded-lg px-3 py-2.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 aria-selected:text-neutral-900 dark:aria-selected:text-white"
>
{active === tab && (
<motion.span
layoutId="active-tab"
transition={{
type: "spring",
duration: reduced ? 0 : 0.3,
bounce: 0.1,
}}
className="absolute inset-0 rounded-lg bg-white dark:bg-[#0a0a0a] shadow-sm ring-1 ring-black/5 dark:ring-white/10"
/>
)}
<span className="relative">{tab}</span>
</button>
))}
</div>
<p
role="tabpanel"
id={`${id}-panel`}
aria-labelledby={`${id}-${active}`}
className="mt-7 text-xs text-neutral-400 dark:text-neutral-500"
>
{active === "Overview"
? "A place for the bigger picture."
: active === "Activity"
? "Every little interaction, in motion."
: "Make yourself at home."}
</p>
</div>
</LayoutGroup>
);
}
React · TypeScript · Tailwind CSS
Copy the component and the cn helper into your project.
bun add motion lucide-react clsx tailwind-merge