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