A pill navigation whose active indicator is a metaball that stretches and merges between items through an SVG goo filter.
npx shadcn@latest add @paragon/gooey-nav"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>
);
}