Gooey Popover
Gooey Popover é um componente de sobreposição para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
overlays
O que é
Gooey Popover é um componente de sobreposição para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i gsap
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
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]);
}