Gravity Tabs

Tabs where the active indicator stretches toward the next tab like liquid pulled by gravity

tabsnavigationflipmetaballspring
Install dependencies
$npm install framer-motion
Preview

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>
  );
}