Gooey Nav
Navigation

Gooey Nav

A pill navigation whose active indicator is a metaball that stretches and merges between items through an SVG goo filter.

Install

npx shadcn@latest add @paragon/gooey-nav

gooey-nav.tsx

"use client";

import * as React from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * GooeyNav — a pill navigation whose active indicator is a metaball: as it
 * moves between items it stretches and merges through an SVG goo filter
 * (blur + contrast threshold), giving the liquid "reveal" feel while staying
 * enterprise-restrained.
 *
 * The two blobs are driven by motion values on transform-x + width: selecting
 * an item springs them (the trailing blob on a lazier spring sells the merge)
 * and springs retarget mid-flight; container resizes `jump()` straight to the
 * new geometry so nothing animates on layout shifts. The first paint is
 * suppressed — the indicator fades in already in place.
 *
 * Real semantics: a `role="tablist"` of `<button role="tab">`s with roving
 * tabindex, Arrow/Home/End navigation, and focus-visible rings. The goo layer
 * is decorative (`aria-hidden`, pointer-events-none). Under reduced motion the
 * indicator jumps without the springy stretch.
 */

export interface GooeyNavItem {
  id: string;
  label: string;
}

export interface GooeyNavProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  items: GooeyNavItem[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  /** Metaball / active color. */
  color?: string;
  /** Active label text color. */
  textColor?: string;
  /** Goo intensity — higher merges harder. 1 is subtle, 3 is very liquid. */
  gooeyness?: number;
}

export function GooeyNav({
  items,
  value,
  defaultValue,
  onValueChange,
  color = "var(--color-primary)",
  textColor = "var(--color-primary-foreground)",
  gooeyness = 1.6,
  className,
  ...props
}: GooeyNavProps) {
  const uid = React.useId().replace(/:/g, "");
  const filterId = `goo-${uid}`;
  const reduce = useReducedMotion();
  const [internal, setInternal] = React.useState(
    defaultValue ?? items[0]?.id,
  );
  const active = value ?? internal;
  const activeIndex = Math.max(
    0,
    items.findIndex((i) => i.id === active),
  );

  const listRef = React.useRef<HTMLDivElement>(null);
  const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const [visible, setVisible] = React.useState(false);
  const measuredRef = React.useRef(false);
  // Mirror activeIndex into a ref so `place` stays referentially stable and
  // the ResizeObserver never reconnects (its initial fire would jump-kill a
  // spring that is mid-flight).
  const activeIndexRef = React.useRef(activeIndex);
  activeIndexRef.current = activeIndex;

  // Leading blob is stiff; the trail lags on a heavier spring so the pair
  // stretches apart and re-merges through the goo threshold.
  const x1 = useMotionValue(0);
  const w1 = useMotionValue(0);
  const x2 = useMotionValue(0);
  const w2 = useMotionValue(0);

  const place = React.useCallback(
    (spring: boolean) => {
      const btn = btnRefs.current[activeIndexRef.current];
      if (!btn) return;
      const left = btn.offsetLeft;
      const width = btn.offsetWidth;
      if (!spring || reduce) {
        x1.jump(left);
        w1.jump(width);
        x2.jump(left);
        w2.jump(width);
      } else {
        const primary = { type: "spring", stiffness: 420, damping: 34, mass: 0.7 } as const;
        const trail = { type: "spring", stiffness: 260, damping: 30, mass: 1 } as const;
        animate(x1, left, primary);
        animate(w1, width, primary);
        animate(x2, left, trail);
        animate(w2, width, trail);
      }
      setVisible(true);
    },
    [reduce, x1, w1, x2, w2],
  );

  React.useLayoutEffect(() => {
    // First placement jumps into position (the layer then fades in over it);
    // every later change springs and retargets mid-flight.
    place(measuredRef.current);
    measuredRef.current = true;
  }, [place, activeIndex]);

  // Layout shifts re-measure and snap — never animate on resize.
  React.useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const ro = new ResizeObserver(() => place(false));
    ro.observe(list);
    return () => ro.disconnect();
  }, [place]);

  React.useEffect(() => {
    return () => {
      x1.stop();
      w1.stop();
      x2.stop();
      w2.stop();
    };
  }, [x1, w1, x2, w2]);

  const select = (id: string) => {
    if (value === undefined) setInternal(id);
    onValueChange?.(id);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    let next: number | null = null;
    if (e.key === "ArrowRight") next = (activeIndex + 1) % items.length;
    else if (e.key === "ArrowLeft")
      next = (activeIndex - 1 + items.length) % items.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = items.length - 1;
    if (next === null) return;
    e.preventDefault();
    select(items[next].id);
    btnRefs.current[next]?.focus();
  };

  return (
    <div
      className={cn("relative inline-block", className)}
      style={
        {
          "--goo-color": color,
          "--goo-text": textColor,
        } as React.CSSProperties
      }
      {...props}
    >
      <svg
        aria-hidden
        className="pointer-events-none absolute size-0"
        focusable="false"
      >
        <defs>
          {/* Expanded filter region so heavy blur never clips at the edges. */}
          <filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
            <feGaussianBlur
              in="SourceGraphic"
              stdDeviation={4 * gooeyness}
              result="blur"
            />
            <feColorMatrix
              in="blur"
              type="matrix"
              values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${
                14 * gooeyness
              } -7`}
            />
          </filter>
        </defs>
      </svg>

      <div
        ref={listRef}
        role="tablist"
        aria-orientation="horizontal"
        onKeyDown={onKeyDown}
        className="relative flex items-center gap-1 rounded-full bg-secondary p-1 shadow-border"
      >
        {/* Goo indicator layer — fades in place on first appearance. */}
        {visible && (
          <div
            aria-hidden
            className="pointer-events-none absolute inset-0 transition-[opacity] duration-150 ease-out starting:opacity-0"
            style={{ filter: `url(#${filterId})` }}
          >
            <motion.div
              className="absolute top-1 bottom-1 left-0 rounded-full"
              style={{ background: color, x: x1, width: w1 }}
            />
            {/* trailing droplet to sell the merge */}
            <motion.div
              className="absolute top-1 bottom-1 left-0 rounded-full"
              style={{ background: color, x: x2, width: w2 }}
            />
          </div>
        )}

        {items.map((item, i) => {
          const isActive = item.id === active;
          return (
            <button
              key={item.id}
              ref={(el) => {
                btnRefs.current[i] = el;
              }}
              role="tab"
              aria-selected={isActive}
              tabIndex={isActive ? 0 : -1}
              onClick={() => select(item.id)}
              className={cn(
                "relative z-10 rounded-full px-4 py-1.5 text-sm font-medium whitespace-nowrap transition-colors duration-150 ease-out",
                "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
                isActive
                  ? "text-[var(--goo-text)]"
                  : "text-muted-foreground hover:text-foreground",
              )}
            >
              {item.label}
            </button>
          );
        })}
      </div>
    </div>
  );
}