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 { motion, 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.
 *
 * Real semantics: a `role="tablist"` of `<button role="tab">`s with roving
 * `aria-selected`, keyboard arrow 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 primarySpring = reduce
    ? { duration: 0 }
    : ({ type: "spring", stiffness: 420, damping: 34, mass: 0.7 } as const);
  const trailSpring = reduce
    ? { duration: 0 }
    : ({ type: "spring", stiffness: 260, damping: 30, mass: 1 } as const);
  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 [rect, setRect] = React.useState<{ left: number; width: number } | null>(
    null,
  );

  const measure = React.useCallback(() => {
    const list = listRef.current;
    const btn = btnRefs.current[activeIndex];
    if (!list || !btn) return;
    const lr = list.getBoundingClientRect();
    const br = btn.getBoundingClientRect();
    setRect({ left: br.left - lr.left, width: br.width });
  }, [activeIndex]);

  React.useLayoutEffect(() => {
    measure();
  }, [measure]);

  React.useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const ro = new ResizeObserver(measure);
    ro.observe(list);
    return () => ro.disconnect();
  }, [measure]);

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

  const onKeyDown = (e: React.KeyboardEvent) => {
    const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (activeIndex + dir + items.length) % items.length;
    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>
          <filter id={filterId}>
            <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 */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0"
          style={{ filter: `url(#${filterId})` }}
        >
          {rect && (
            <>
              <motion.div
                className="absolute top-1 bottom-1 rounded-full"
                style={{ background: color }}
                animate={{ left: rect.left, width: rect.width }}
                transition={primarySpring}
              />
              {/* trailing droplet to sell the merge */}
              <motion.div
                className="absolute top-1 bottom-1 rounded-full"
                style={{ background: color }}
                animate={{ left: rect.left, width: rect.width }}
                transition={trailSpring}
              />
            </>
          )}
        </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>
  );
}