Gooey Popover
Gooey Popover is a React and Tailwind CSS overlay component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
overlays
What it is
Gooey Popover is a React and Tailwind CSS overlay component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i gsap
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Gooey Popover
"use client";
import { cn } from "@/lib/utils";
import gsap from "gsap";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useClickOutside } from "./use-click-outside";
const MEASURE_DELAY_SHORT = 100;
const MEASURE_DELAY_LONG = 500;
const DEFAULT_TRIGGER_SIZE = 44;
const DEFAULT_CONTENT_WIDTH = 240;
const DEFAULT_SIDE_OFFSET = 24;
const DEFAULT_SPEED = 0.25;
const GOO_STD_DEVIATION = 10;
const GOO_MATRIX_ALPHA_MULTIPLIER = 24;
const GOO_MATRIX_ALPHA_OFFSET = -10;
const CONTENT_BORDER_RADIUS = 18;
export type GooeyPopoverProps = {
children: React.ReactNode;
trigger?: React.ReactNode;
triggerSize?: number;
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
side?: "top" | "bottom";
sideOffset?: number;
contentWidth?: number;
speed?: number;
bgClassName?: string;
contentClassName?: string;
className?: string;
};
export default function GooeyPopover({
children,
trigger,
triggerSize = DEFAULT_TRIGGER_SIZE,
isOpen: controlledIsOpen,
onOpenChange,
side = "top",
sideOffset = DEFAULT_SIDE_OFFSET,
contentWidth = DEFAULT_CONTENT_WIDTH,
speed = DEFAULT_SPEED,
bgClassName = "bg-neutral-900",
contentClassName,
className,
}: GooeyPopoverProps) {
const filterId = useId();
const isControlled = controlledIsOpen !== undefined;
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const filteredContentRef = useRef<HTMLDivElement>(null);
const unfilteredContentRef = useRef<HTMLDivElement>(null);
const innerContentRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const [contentHeight, setContentHeight] = useState(0);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReducedMotion(mq.matches);
const handler = (e: MediaQueryListEvent) => {
setPrefersReducedMotion(e.matches);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const setIsOpen = useCallback(
(open: boolean) => {
if (!isControlled) {
setInternalIsOpen(open);
}
onOpenChange?.(open);
},
[isControlled, onOpenChange]
);
const handleClose = useCallback(() => {
if (isOpen) {
setIsOpen(false);
}
}, [isOpen, setIsOpen]);
useClickOutside(containerRef, handleClose);
// Escape key to close
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) {
setIsOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, setIsOpen]);
// Measure content height
useEffect(() => {
const measureHeight = () => {
if (measureRef.current) {
const height = measureRef.current.scrollHeight;
if (height > 0) {
setContentHeight(height);
}
}
};
const timeoutId = setTimeout(measureHeight, MEASURE_DELAY_SHORT);
const timeoutId2 = setTimeout(measureHeight, MEASURE_DELAY_LONG);
return () => {
clearTimeout(timeoutId);
clearTimeout(timeoutId2);
};
}, [children]);
const triggerRadius = triggerSize / 2;
// Position content so its edge clears the trigger with sideOffset gap
const translateY =
side === "top" ? -(contentHeight + sideOffset) : triggerSize + sideOffset;
const contentLeft = triggerRadius - contentWidth / 2;
// GSAP animations
useEffect(() => {
if (contentHeight === 0) {
return;
}
// Kill any running timeline
if (timelineRef.current) {
timelineRef.current.kill();
}
const filteredTarget = filteredContentRef.current;
const unfilteredTarget = unfilteredContentRef.current;
const innerTarget = innerContentRef.current;
if (!(unfilteredTarget && innerTarget)) {
return;
}
if (prefersReducedMotion) {
if (isOpen) {
setIsVisible(true);
gsap.set(unfilteredTarget, {
borderRadius: CONTENT_BORDER_RADIUS,
height: contentHeight,
opacity: 1,
width: contentWidth,
x: contentLeft,
y: translateY,
});
gsap.set(innerTarget, { opacity: 1, y: 0 });
} else {
gsap.set(unfilteredTarget, {
borderRadius: triggerRadius,
height: triggerSize,
opacity: 0,
width: triggerSize,
x: 0,
y: 0,
});
gsap.set(innerTarget, { opacity: 0, y: 0 });
setIsVisible(false);
}
return;
}
if (isOpen) {
setIsVisible(true);
// Start both content shapes as circles at trigger position
const startProps = {
borderRadius: triggerRadius,
height: triggerSize,
opacity: 1,
width: triggerSize,
x: 0,
y: 0,
};
if (filteredTarget) {
gsap.set(filteredTarget, startProps);
}
gsap.set(unfilteredTarget, startProps);
gsap.set(innerTarget, { opacity: 0, y: 16 });
const tl = gsap.timeline();
// Filtered content: morph to rectangle with borderRadius=0
// The goo filter softens the edges naturally
if (filteredTarget) {
tl.to(
filteredTarget,
{
borderRadius: 0,
duration: speed,
ease: "power1.in",
height: contentHeight,
width: contentWidth,
x: contentLeft,
y: translateY,
},
0
);
}
// Unfiltered content: morph to rectangle with rounded corners
tl.to(
unfilteredTarget,
{
borderRadius: CONTENT_BORDER_RADIUS,
duration: speed,
ease: "power1.in",
height: contentHeight,
width: contentWidth,
x: contentLeft,
y: translateY,
},
0
);
// Content text fade in (overlapping with shape morph)
tl.to(
innerTarget,
{
duration: speed * 0.75,
ease: "power1.out",
opacity: 1,
y: 0,
},
speed * 0.575
);
timelineRef.current = tl;
} else {
// Content text fade out first
const tl = gsap.timeline({
onComplete: () => {
setIsVisible(false);
},
});
tl.to(innerTarget, {
duration: speed * 0.4,
ease: "power1.in",
opacity: 0,
y: 8,
});
// Shape morph back: rectangle → circle
const targets = [filteredTarget, unfilteredTarget].filter(Boolean);
tl.to(
targets,
{
borderRadius: triggerRadius,
duration: speed,
ease: "power1.in",
height: triggerSize,
width: triggerSize,
x: 0,
y: 0,
},
speed * 0.2
);
// Fade out at the end
tl.to(
targets,
{
duration: speed * 0.3,
ease: "power1.in",
opacity: 0,
},
`-=${speed * 0.3}`
);
timelineRef.current = tl;
}
return () => {
if (timelineRef.current) {
timelineRef.current.kill();
}
};
}, [
isOpen,
contentHeight,
contentWidth,
triggerSize,
triggerRadius,
contentLeft,
translateY,
speed,
prefersReducedMotion,
]);
const defaultTriggerIcon = (
<svg
fill="none"
height={20}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<line x1="12" x2="12" y1="5" y2="19" />
<line x1="5" x2="19" y1="12" y2="12" />
</svg>
);
return (
<div className={cn("relative inline-flex", className)} ref={containerRef}>
{/* SVG goo filter definition */}
<svg
aria-hidden="true"
className="absolute"
style={{ height: 0, width: 0 }}
>
<defs>
<filter id={filterId}>
<feGaussianBlur
in="SourceGraphic"
result="blur"
stdDeviation={GOO_STD_DEVIATION}
/>
<feColorMatrix
in="blur"
result="goo"
type="matrix"
values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${String(GOO_MATRIX_ALPHA_MULTIPLIER)} ${String(GOO_MATRIX_ALPHA_OFFSET)}`}
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* Hidden measurement div */}
<div
aria-hidden="true"
className="pointer-events-none absolute"
ref={measureRef}
style={{
left: -9999,
position: "absolute",
top: -9999,
visibility: "hidden",
width: contentWidth,
}}
>
<div className={cn("p-4", contentClassName)}>{children}</div>
</div>
{/* Filtered layer: SVG goo filter creates liquid bridge between blob and content */}
{!prefersReducedMotion && (isOpen || isVisible) && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{ filter: `url(#${filterId})` }}
>
{/* Blob: circle at trigger position — stays put while content morphs away */}
<div
className={cn("absolute rounded-full", bgClassName)}
style={{
height: triggerSize,
left: 0,
top: 0,
width: triggerSize,
}}
/>
{/* Content shape: morphs from circle to rectangle (borderRadius=0, goo softens edges) */}
<div
className={cn("absolute", bgClassName)}
ref={filteredContentRef}
style={{
borderRadius: triggerRadius,
height: triggerSize,
left: 0,
opacity: 0,
top: 0,
width: triggerSize,
}}
/>
</div>
)}
{/* Trigger button (z-10, sits on top of filtered blob) */}
<button
aria-expanded={isOpen}
aria-haspopup="dialog"
className={cn(
"relative z-10 flex cursor-pointer items-center justify-center rounded-full text-white transition-colors",
bgClassName
)}
onClick={() => setIsOpen(!isOpen)}
style={{
height: triggerSize,
width: triggerSize,
}}
type="button"
>
{trigger ?? defaultTriggerIcon}
</button>
{/* Unfiltered content panel (z-10, clean edges, actual text) */}
{isOpen || isVisible ? (
<div
className={cn(
"absolute z-10 overflow-hidden text-white",
bgClassName
)}
ref={unfilteredContentRef}
role="dialog"
style={{
borderRadius: triggerRadius,
height: triggerSize,
left: 0,
opacity: 0,
top: 0,
width: triggerSize,
}}
>
<div
className={cn("p-4", contentClassName)}
ref={innerContentRef}
style={{ opacity: 0, transform: "translateY(16px)" }}
>
{children}
</div>
</div>
) : null}
</div>
);
}
"use client";
import { type RefObject, useEffect } from "react";
type EventType =
| "mousedown"
| "mouseup"
| "touchstart"
| "touchend"
| "focusin"
| "focusout";
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T | null> | RefObject<T | null>[],
handler: (event: Event) => void,
eventType: EventType = "mousedown"
): void {
useEffect(() => {
function callback(event: Event) {
const target = event.target as Node;
// Do nothing if the target is not connected element with document
if (!target?.isConnected) {
return;
}
const isOutside = Array.isArray(ref)
? ref
.filter((r) => Boolean(r.current))
.every((r) => r.current && !r.current.contains(target))
: ref.current && !ref.current.contains(target);
if (isOutside) {
handler(event);
}
}
window.addEventListener(eventType, callback);
return () => {
window.removeEventListener(eventType, callback);
};
}, [ref, handler, eventType]);
}