Gravity Tabs
Tabs where the active indicator stretches toward the next tab like liquid pulled by gravity
tabsnavigationflipmetaballspring
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { motion } from "framer-motion";
import { useEffect, useRef, KeyboardEvent } from "react";
import { cn } from "@/lib/utils";
export interface Tab {
id: string;
label: string;
icon?: React.ReactNode;
}
export interface GravityTabsProps {
tabs: Tab[];
value: string;
onChange: (id: string) => void;
/** Stretch intensity — higher = softer trailing spring. Default: 1 */
elasticity?: number;
/** Visual variant. Default: 'pill' */
variant?: "pill" | "underline";
className?: string;
}
export default function GravityTabs({
tabs,
value,
onChange,
elasticity = 1,
variant = "pill",
className,
}: GravityTabsProps) {
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const isPill = variant === "pill";
const activeIndex = Math.max(0, tabs.findIndex((t) => t.id === value));
const tabCount = tabs.length;
const prevIndexRef = useRef(activeIndex);
const direction = activeIndex - prevIndexRef.current;
useEffect(() => {
prevIndexRef.current = activeIndex;
}, [activeIndex]);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const currentIndex = tabs.findIndex((t) => t.id === value);
if (e.key === "ArrowRight") {
const nextIdx = (currentIndex + 1) % tabs.length;
onChange(tabs[nextIdx].id);
tabRefs.current[nextIdx]?.focus();
} else if (e.key === "ArrowLeft") {
const prevIdx = (currentIndex - 1 + tabs.length) % tabs.length;
onChange(tabs[prevIdx].id);
tabRefs.current[prevIdx]?.focus();
}
}
const fastSpring = { type: "spring" as const, stiffness: 400, damping: 30 };
const slowSpring = {
type: "spring" as const,
stiffness: Math.max(60, 180 / elasticity),
damping: 18,
};
const blobPosition = {
left: `${(activeIndex / tabCount) * 100}%`,
width: `${100 / tabCount}%`,
};
const leftPct = `${(activeIndex / tabCount) * 100}%`;
const rightPct = `${((tabCount - activeIndex - 1) / tabCount) * 100}%`;
return (
<div className={cn("relative inline-flex", className)}>
{isPill && (
<svg width="0" height="0" className="absolute">
<defs>
<filter id="gravity-goo">
<feGaussianBlur
in="SourceGraphic"
stdDeviation="8"
result="blur"
/>
<feColorMatrix
in="blur"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10"
/>
</filter>
</defs>
</svg>
)}
<div
className={cn(
isPill
? "rounded-full bg-white/10 p-1"
: "border-b border-white/15"
)}
>
<div
role="tablist"
onKeyDown={handleKeyDown}
className="relative flex items-stretch"
>
{isPill ? (
// Two blobs under a shared goo filter fuse into one stretched shape mid-transition
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{ filter: "url(#gravity-goo)" }}
>
<motion.span
className="absolute inset-y-0 rounded-full bg-violet-500"
initial={blobPosition}
animate={blobPosition}
transition={slowSpring}
/>
<motion.span
className="absolute inset-y-0 rounded-full bg-violet-500"
initial={blobPosition}
animate={blobPosition}
transition={fastSpring}
/>
</div>
) : (
// Leading edge snaps to target, trailing edge lags — bar stretches with direction of travel
<motion.span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 h-[3px] rounded-t-full bg-violet-500"
initial={{ left: leftPct, right: rightPct }}
animate={{ left: leftPct, right: rightPct }}
transition={{
left: direction >= 0 ? slowSpring : fastSpring,
right: direction >= 0 ? fastSpring : slowSpring,
}}
/>
)}
{tabs.map((tab, i) => {
const isActive = tab.id === value;
return (
<button
key={tab.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(tab.id)}
className={cn(
"relative z-10 flex flex-1 items-center justify-center gap-1.5 select-none whitespace-nowrap transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1",
isPill
? "rounded-full px-4 py-1.5 text-sm font-medium"
: "px-4 pb-2.5 pt-1.5 text-sm font-medium",
isActive ? "text-white" : "text-white/50 hover:text-white/80"
)}
>
{tab.icon && <span className="shrink-0">{tab.icon}</span>}
{tab.label}
</button>
);
})}
</div>
</div>
</div>
);
}