"use client";
import * as React from "react";
import { AnimatePresence, motion, useIsPresent, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface AnimatedTab {
id: string;
label: string;
icon?: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
}
export interface AnimatedTabsProps {
tabs: AnimatedTab[];
/** Uncontrolled initial tab id. Defaults to the first enabled tab. */
defaultValue?: string;
/** Controlled tab id. */
value?: string;
onValueChange?: (id: string) => void;
/** "pill" = filled sliding pill, "underline" = sliding bar under the label. */
variant?: "pill" | "underline";
/** Accessible name for the tab list. */
label?: string;
className?: string;
listClassName?: string;
panelClassName?: string;
}
const spring = { type: "spring", stiffness: 420, damping: 34, mass: 0.8 } as const;
export function AnimatedTabs({
tabs,
defaultValue,
value,
onValueChange,
variant = "pill",
label = "Tabs",
className,
listClassName,
panelClassName,
}: AnimatedTabsProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const firstEnabled = tabs.find((t) => !t.disabled)?.id ?? "";
const [inner, setInner] = React.useState(defaultValue ?? firstEnabled);
const current = value ?? inner;
const index = Math.max(0, tabs.findIndex((t) => t.id === current));
const [direction, setDirection] = React.useState(0);
const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = (id: string) => {
const next = tabs.findIndex((t) => t.id === id);
setDirection(next > index ? 1 : -1);
if (value === undefined) setInner(id);
onValueChange?.(id);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const enabled = tabs.map((t, i) => (t.disabled ? -1 : i)).filter((i) => i >= 0);
const pos = enabled.indexOf(index);
let target: number | undefined;
if (e.key === "ArrowRight" || e.key === "ArrowDown") target = enabled[(pos + 1) % enabled.length];
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") target = enabled[(pos - 1 + enabled.length) % enabled.length];
else if (e.key === "Home") target = enabled[0];
else if (e.key === "End") target = enabled[enabled.length - 1];
if (target === undefined) return;
e.preventDefault();
select(tabs[target].id);
tabRefs.current[target]?.focus();
};
// The active panel reports its height so the container animates instead of jumping.
const [height, setHeight] = React.useState<number | "auto">("auto");
const active = tabs[index];
const distance = reduce ? 0 : 24;
return (
<div className={cn("w-full", className)}>
<div
role="tablist"
aria-label={label}
onKeyDown={onKeyDown}
className={cn(
"relative flex w-full gap-1 overflow-x-auto [scrollbar-width:none]",
variant === "pill" ? "rounded-full border bg-muted/60 p-1" : "border-b",
listClassName,
)}
>
{tabs.map((tab, i) => {
const selected = i === index;
return (
<button
key={tab.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
role="tab"
type="button"
id={`${uid}-tab-${tab.id}`}
aria-selected={selected}
aria-controls={`${uid}-panel-${tab.id}`}
tabIndex={selected ? 0 : -1}
disabled={tab.disabled}
onClick={() => select(tab.id)}
className={cn(
"relative flex flex-1 shrink-0 items-center justify-center gap-2 whitespace-nowrap px-2.5 py-2 sm:px-3 text-sm font-medium transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-40",
variant === "pill" ? "rounded-full" : "rounded-md pb-3",
selected ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{selected && (
<motion.span
layoutId={`${uid}-indicator`}
transition={reduce ? { duration: 0 } : spring}
className={cn(
"absolute",
variant === "pill"
? "inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
: "inset-x-2 -bottom-px h-0.5 rounded-full bg-primary",
)}
/>
)}
<span className="relative z-10 flex items-center gap-2">
{tab.icon && <span className="flex max-sm:hidden">{tab.icon}</span>}
{tab.label}
</span>
</button>
);
})}
</div>
<motion.div
animate={{ height }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 32 }}
className="relative overflow-hidden"
>
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.div
key={active?.id}
role="tabpanel"
id={`${uid}-panel-${active?.id}`}
aria-labelledby={`${uid}-tab-${active?.id}`}
tabIndex={0}
custom={direction}
variants={{
enter: (d: number) => ({ opacity: 0, x: d * distance, filter: reduce ? "none" : "blur(4px)" }),
center: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: (d: number) => ({ opacity: 0, x: d * -distance, filter: reduce ? "none" : "blur(4px)" }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className={cn("rounded-xl pt-4 outline-none focus-visible:ring-2 focus-visible:ring-ring", panelClassName)}
>
<Measure onHeight={setHeight}>{active?.content}</Measure>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
);
}
function Measure({ onHeight, children }: { onHeight: (h: number) => void; children: React.ReactNode }) {
const ref = React.useRef<HTMLDivElement>(null);
const present = useIsPresent();
const presentRef = React.useRef(present);
React.useEffect(() => {
presentRef.current = present;
}, [present]);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(() => {
// Exiting panels keep rendering during their exit animation; only the live one sets the height.
if (presentRef.current && el.parentElement) onHeight(el.parentElement.offsetHeight);
});
ro.observe(el);
return () => ro.disconnect();
}, [onHeight]);
return <div ref={ref}>{children}</div>;
}