Popover
NewGooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top. Click or hover trigger, controlled or uncontrolled.
Dimensions
Set the width and height for the layer.
Opens on hover, with a grace window so you can move into the panel.
"use client";
import { Button } from "@/components/motion/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/motion/popover";
export function PopoverPreview() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<Popover side="bottom" align="start">
<PopoverTrigger>
<Button variant="secondary">Edit profile</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<p className="text-sm font-medium text-foreground">Dimensions</p>
<p className="mt-1 text-xs text-muted-foreground">
Set the width and height for the layer.
</p>
<div className="mt-3 flex flex-col gap-2">
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Width</span>
<input
defaultValue="100%"
className="h-8 w-32 rounded-lg border border-border bg-background px-2.5 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-foreground/20"
/>
</label>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Height</span>
<input
defaultValue="auto"
className="h-8 w-32 rounded-lg border border-border bg-background px-2.5 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-foreground/20"
/>
</label>
</div>
</PopoverContent>
</Popover>
<Popover trigger="hover" side="top">
<PopoverTrigger>
<Button variant="outline">Hover me</Button>
</PopoverTrigger>
<PopoverContent className="w-56">
<p className="text-sm text-foreground">
Opens on hover, with a grace window so you can move into the panel.
</p>
</PopoverContent>
</Popover>
</div>
);
}
"use client";
// beui.dev/components/motion/popover
import {
animate,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
type MotionValue,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
type TriggerMode = "click" | "hover";
const GOO_SPRING = { type: "spring", visualDuration: 0.32, bounce: 0.28 } as const;
const HOVER_CLOSE_DELAY = 120;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
interface Rect {
x: number;
y: number;
w: number;
h: number;
r: number;
}
interface Geo {
layerW: number;
layerH: number;
left: number;
top: number;
trigger: Rect;
panel: Rect;
}
// Trigger rect and panel rect in a shared local coordinate box.
function buildGeo(
tW: number,
tH: number,
cW: number,
cH: number,
side: Side,
align: Align,
gap: number,
panelRadius: number,
): Geo {
const py = side === "bottom" ? tH + gap : -(gap + cH);
const px = align === "start" ? 0 : align === "end" ? tW - cW : (tW - cW) / 2;
const left = Math.min(0, px);
const top = Math.min(0, py);
const layerW = Math.max(tW, px + cW) - left;
const layerH = Math.max(tH, py + cH) - top;
const triggerRadius = Math.min(tH / 2, panelRadius);
return {
layerW,
layerH,
left,
top,
trigger: { x: -left, y: -top, w: tW, h: tH, r: triggerRadius },
panel: { x: px - left, y: py - top, w: cW, h: cH, r: panelRadius },
};
}
function insetFor(rect: Rect, layerW: number, layerH: number): string {
const top = rect.y;
const right = layerW - (rect.x + rect.w);
const bottom = layerH - (rect.y + rect.h);
const left = rect.x;
return `inset(${top}px ${right}px ${bottom}px ${left}px round ${rect.r}px)`;
}
function insetForProgress(geo: Geo, p: number): string {
const t = geo.trigger;
const pn = geo.panel;
const rect: Rect = {
x: lerp(t.x, pn.x, p),
y: lerp(t.y, pn.y, p),
w: lerp(t.w, pn.w, p),
h: lerp(t.h, pn.h, p),
r: lerp(t.r, pn.r, p),
};
return insetFor(rect, geo.layerW, geo.layerH);
}
interface PopoverContextValue {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
openHover: () => void;
scheduleClose: () => void;
triggerMode: TriggerMode;
side: Side;
align: Align;
gap: number;
panelRadius: number;
gooStrength: number;
reduce: boolean;
gooId: string;
contentId: string;
progress: MotionValue<number>;
triggerRef: React.MutableRefObject<HTMLElement | null>;
}
const PopoverContext = createContext<PopoverContextValue | null>(null);
function usePopoverContext(component: string) {
const ctx = useContext(PopoverContext);
if (!ctx) throw new Error(`${component} must be used within <Popover>`);
return ctx;
}
export interface PopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** How the popover is summoned. Default "click". */
trigger?: TriggerMode;
/** Which side of the trigger the panel oozes out of. Default "bottom". */
side?: Side;
/** Alignment along the trigger's edge. Default "center". */
align?: Align;
/** Gap between trigger and panel, in px — the length of the gooey neck. Default 14. */
sideOffset?: number;
/** Corner radius of the open panel, in px. Default 16. */
panelRadius?: number;
/** Blur radius feeding the goo filter — higher melts more. Default 8. */
gooStrength?: number;
className?: string;
}
export function Popover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
trigger = "click",
side = "bottom",
align = "center",
sideOffset = 14,
panelRadius = 16,
gooStrength = 8,
className,
}: PopoverProps) {
const reduce = useReducedMotion() ?? false;
const gooId = useId().replace(/:/g, "");
const contentId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const progress = useMotionValue(defaultOpen ? 1 : 0);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const cancelClose = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const openHover = useCallback(() => {
cancelClose();
setOpen(true);
}, [cancelClose, setOpen]);
const scheduleClose = useCallback(() => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY);
}, [cancelClose, setOpen]);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
useEffect(() => () => cancelClose(), [cancelClose]);
useEffect(() => {
const animation = animate(
progress,
open ? 1 : 0,
reduce ? { duration: 0 } : GOO_SPRING,
);
return () => animation.stop();
}, [open, progress, reduce]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
// Trigger and panel share rootRef, so moving between them isn't "outside".
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
if (trigger === "click") window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, setOpen, trigger]);
const ctx = useMemo<PopoverContextValue>(
() => ({
open,
setOpen,
toggle,
openHover,
scheduleClose,
triggerMode: trigger,
side,
align,
gap: sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
}),
[
open,
setOpen,
toggle,
openHover,
scheduleClose,
trigger,
side,
align,
sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
],
);
const hoverHandlers =
trigger === "hover"
? { onMouseEnter: openHover, onMouseLeave: scheduleClose }
: {};
return (
<PopoverContext.Provider value={ctx}>
<div
ref={rootRef}
className={cn("relative inline-flex isolate", className)}
{...hoverHandlers}
>
{children}
</div>
</PopoverContext.Provider>
);
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
export interface PopoverTriggerProps {
/** A single focusable element (e.g. a Button) that opens the popover. */
children: ReactElement;
}
export function PopoverTrigger({ children }: PopoverTriggerProps) {
const ctx = usePopoverContext("PopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childProps = child.props;
const childRef = (childProps as { ref?: Ref<HTMLElement> }).ref;
const compose =
(name: string, handler: () => void) =>
(event: { defaultPrevented?: boolean }) => {
(childProps[name] as ((e: unknown) => void) | undefined)?.(event);
if (!event.defaultPrevented) handler();
};
const handlers: Record<string, unknown> =
ctx.triggerMode === "hover"
? {
onFocus: compose("onFocus", ctx.openHover),
onBlur: compose("onBlur", ctx.scheduleClose),
}
: { onClick: compose("onClick", ctx.toggle) };
return cloneElement(child, {
...handlers,
ref: mergeRefs(childRef, (node: HTMLElement | null) => {
ctx.triggerRef.current = node;
}),
// Above the goo layer (z-[-1]) so the neck reads behind it.
className: cn("relative z-0", childProps.className as string | undefined),
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
"data-state": ctx.open ? "open" : "closed",
});
}
const ALIGN_ORIGIN: Record<Align, string> = {
start: "left",
center: "center",
end: "right",
};
export interface PopoverContentProps {
children: ReactNode;
className?: string;
}
export function PopoverContent({ children, className }: PopoverContentProps) {
const ctx = usePopoverContext("PopoverContent");
const {
side,
align,
gap,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
open,
triggerMode,
openHover,
scheduleClose,
} = ctx;
const measureRef = useRef<HTMLDivElement>(null);
const blobRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
const geoRef = useRef<Geo | null>(null);
const [sizes, setSizes] = useState({ tW: 0, tH: 0, cW: 0, cH: 0 });
useLayoutEffect(() => {
const triggerNode = triggerRef.current;
const contentNode = measureRef.current;
if (!contentNode) return;
const measure = () => {
const tW = triggerNode?.offsetWidth ?? 0;
const tH = triggerNode?.offsetHeight ?? 0;
const cW = contentNode.offsetWidth;
const cH = contentNode.offsetHeight;
setSizes((prev) =>
prev.tW === tW && prev.tH === tH && prev.cW === cW && prev.cH === cH
? prev
: { tW, tH, cW, cH },
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(contentNode);
if (triggerNode) observer.observe(triggerNode);
return () => observer.disconnect();
}, [triggerRef]);
const geo = useMemo(
() => buildGeo(sizes.tW, sizes.tH, sizes.cW, sizes.cH, side, align, gap, panelRadius),
[sizes, side, align, gap, panelRadius],
);
geoRef.current = geo;
// Morph the same clip on the goo body and the content, so the whole popover
// oozes as one and the text reveals with it.
const render = useCallback((g: Geo | null, p: number) => {
if (!g || g.layerW === 0) return;
const clip = insetForProgress(g, p);
if (blobRef.current) blobRef.current.style.clipPath = clip;
if (clipRef.current) clipRef.current.style.clipPath = clip;
}, []);
useLayoutEffect(() => {
render(geo, progress.get());
}, [geo, progress, render]);
useMotionValueEvent(progress, "change", (p) => render(geoRef.current, p));
const hoverHandlers =
triggerMode === "hover"
? { onMouseEnter: openHover, onMouseLeave: scheduleClose }
: {};
return (
<>
{/* Goo filter: blur, sharpen the alpha back into solid shapes, then lay
the crisp original on top so blobs merge with liquid edges. */}
<svg aria-hidden width="0" height="0" className="pointer-events-none absolute">
<title>Popover goo filter</title>
<defs>
<filter id={gooId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation={gooStrength} result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* Goo body: static trigger pill + morphing blob, behind the trigger. */}
<div
aria-hidden
className="pointer-events-none absolute z-[-1]"
style={{
left: geo.left,
top: geo.top,
width: geo.layerW,
height: geo.layerH,
filter: reduce ? undefined : `url(#${gooId})`,
}}
>
<div
className="absolute bg-popover"
style={{
left: geo.trigger.x,
top: geo.trigger.y,
width: geo.trigger.w,
height: geo.trigger.h,
borderRadius: geo.trigger.r,
}}
/>
<div
ref={blobRef}
className="absolute inset-0 bg-popover"
style={{ clipPath: insetForProgress(geo, progress.get()) }}
/>
</div>
{/* Content, clipped by the same morph. pointer-events-none so it never
shadows the trigger; the open panel re-enables its own. */}
<div
className="pointer-events-none absolute z-10"
style={{ left: geo.left, top: geo.top, width: geo.layerW, height: geo.layerH }}
>
<div
ref={clipRef}
inert={!open}
className="absolute inset-0"
style={{
clipPath: insetForProgress(geo, progress.get()),
pointerEvents: open ? "auto" : "none",
}}
>
<div
ref={measureRef}
id={contentId}
role="dialog"
{...hoverHandlers}
style={{
position: "absolute",
left: geo.panel.x,
top: geo.panel.y,
transformOrigin: `${ALIGN_ORIGIN[align]} ${side === "bottom" ? "top" : "bottom"}`,
}}
className={cn(
"w-max max-w-[min(92vw,20rem)] p-4 text-popover-foreground outline-none",
className,
)}
>
{children}
</div>
</div>
</div>
</>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/components/motion/popover
import {
animate,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
type MotionValue,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
type TriggerMode = "click" | "hover";
const GOO_SPRING = { type: "spring", visualDuration: 0.32, bounce: 0.28 } as const;
const HOVER_CLOSE_DELAY = 120;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
interface Rect {
x: number;
y: number;
w: number;
h: number;
r: number;
}
interface Geo {
layerW: number;
layerH: number;
left: number;
top: number;
trigger: Rect;
panel: Rect;
}
// Trigger rect and panel rect in a shared local coordinate box.
function buildGeo(
tW: number,
tH: number,
cW: number,
cH: number,
side: Side,
align: Align,
gap: number,
panelRadius: number,
): Geo {
const py = side === "bottom" ? tH + gap : -(gap + cH);
const px = align === "start" ? 0 : align === "end" ? tW - cW : (tW - cW) / 2;
const left = Math.min(0, px);
const top = Math.min(0, py);
const layerW = Math.max(tW, px + cW) - left;
const layerH = Math.max(tH, py + cH) - top;
const triggerRadius = Math.min(tH / 2, panelRadius);
return {
layerW,
layerH,
left,
top,
trigger: { x: -left, y: -top, w: tW, h: tH, r: triggerRadius },
panel: { x: px - left, y: py - top, w: cW, h: cH, r: panelRadius },
};
}
function insetFor(rect: Rect, layerW: number, layerH: number): string {
const top = rect.y;
const right = layerW - (rect.x + rect.w);
const bottom = layerH - (rect.y + rect.h);
const left = rect.x;
return `inset(${top}px ${right}px ${bottom}px ${left}px round ${rect.r}px)`;
}
function insetForProgress(geo: Geo, p: number): string {
const t = geo.trigger;
const pn = geo.panel;
const rect: Rect = {
x: lerp(t.x, pn.x, p),
y: lerp(t.y, pn.y, p),
w: lerp(t.w, pn.w, p),
h: lerp(t.h, pn.h, p),
r: lerp(t.r, pn.r, p),
};
return insetFor(rect, geo.layerW, geo.layerH);
}
interface PopoverContextValue {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
openHover: () => void;
scheduleClose: () => void;
triggerMode: TriggerMode;
side: Side;
align: Align;
gap: number;
panelRadius: number;
gooStrength: number;
reduce: boolean;
gooId: string;
contentId: string;
progress: MotionValue<number>;
triggerRef: React.MutableRefObject<HTMLElement | null>;
}
const PopoverContext = createContext<PopoverContextValue | null>(null);
function usePopoverContext(component: string) {
const ctx = useContext(PopoverContext);
if (!ctx) throw new Error(`${component} must be used within <Popover>`);
return ctx;
}
export interface PopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** How the popover is summoned. Default "click". */
trigger?: TriggerMode;
/** Which side of the trigger the panel oozes out of. Default "bottom". */
side?: Side;
/** Alignment along the trigger's edge. Default "center". */
align?: Align;
/** Gap between trigger and panel, in px — the length of the gooey neck. Default 14. */
sideOffset?: number;
/** Corner radius of the open panel, in px. Default 16. */
panelRadius?: number;
/** Blur radius feeding the goo filter — higher melts more. Default 8. */
gooStrength?: number;
className?: string;
}
export function Popover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
trigger = "click",
side = "bottom",
align = "center",
sideOffset = 14,
panelRadius = 16,
gooStrength = 8,
className,
}: PopoverProps) {
const reduce = useReducedMotion() ?? false;
const gooId = useId().replace(/:/g, "");
const contentId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const progress = useMotionValue(defaultOpen ? 1 : 0);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const cancelClose = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const openHover = useCallback(() => {
cancelClose();
setOpen(true);
}, [cancelClose, setOpen]);
const scheduleClose = useCallback(() => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY);
}, [cancelClose, setOpen]);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
useEffect(() => () => cancelClose(), [cancelClose]);
useEffect(() => {
const animation = animate(
progress,
open ? 1 : 0,
reduce ? { duration: 0 } : GOO_SPRING,
);
return () => animation.stop();
}, [open, progress, reduce]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
// Trigger and panel share rootRef, so moving between them isn't "outside".
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
if (trigger === "click") window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, setOpen, trigger]);
const ctx = useMemo<PopoverContextValue>(
() => ({
open,
setOpen,
toggle,
openHover,
scheduleClose,
triggerMode: trigger,
side,
align,
gap: sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
}),
[
open,
setOpen,
toggle,
openHover,
scheduleClose,
trigger,
side,
align,
sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
],
);
const hoverHandlers =
trigger === "hover"
? { onMouseEnter: openHover, onMouseLeave: scheduleClose }
: {};
return (
<PopoverContext.Provider value={ctx}>
<div
ref={rootRef}
className={cn("relative inline-flex isolate", className)}
{...hoverHandlers}
>
{children}
</div>
</PopoverContext.Provider>
);
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
export interface PopoverTriggerProps {
/** A single focusable element (e.g. a Button) that opens the popover. */
children: ReactElement;
}
export function PopoverTrigger({ children }: PopoverTriggerProps) {
const ctx = usePopoverContext("PopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childProps = child.props;
const childRef = (childProps as { ref?: Ref<HTMLElement> }).ref;
const compose =
(name: string, handler: () => void) =>
(event: { defaultPrevented?: boolean }) => {
(childProps[name] as ((e: unknown) => void) | undefined)?.(event);
if (!event.defaultPrevented) handler();
};
const handlers: Record<string, unknown> =
ctx.triggerMode === "hover"
? {
onFocus: compose("onFocus", ctx.openHover),
onBlur: compose("onBlur", ctx.scheduleClose),
}
: { onClick: compose("onClick", ctx.toggle) };
return cloneElement(child, {
...handlers,
ref: mergeRefs(childRef, (node: HTMLElement | null) => {
ctx.triggerRef.current = node;
}),
// Above the goo layer (z-[-1]) so the neck reads behind it.
className: cn("relative z-0", childProps.className as string | undefined),
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
"data-state": ctx.open ? "open" : "closed",
});
}
const ALIGN_ORIGIN: Record<Align, string> = {
start: "left",
center: "center",
end: "right",
};
export interface PopoverContentProps {
children: ReactNode;
className?: string;
}
export function PopoverContent({ children, className }: PopoverContentProps) {
const ctx = usePopoverContext("PopoverContent");
const {
side,
align,
gap,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
open,
triggerMode,
openHover,
scheduleClose,
} = ctx;
const measureRef = useRef<HTMLDivElement>(null);
const blobRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
const geoRef = useRef<Geo | null>(null);
const [sizes, setSizes] = useState({ tW: 0, tH: 0, cW: 0, cH: 0 });
useLayoutEffect(() => {
const triggerNode = triggerRef.current;
const contentNode = measureRef.current;
if (!contentNode) return;
const measure = () => {
const tW = triggerNode?.offsetWidth ?? 0;
const tH = triggerNode?.offsetHeight ?? 0;
const cW = contentNode.offsetWidth;
const cH = contentNode.offsetHeight;
setSizes((prev) =>
prev.tW === tW && prev.tH === tH && prev.cW === cW && prev.cH === cH
? prev
: { tW, tH, cW, cH },
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(contentNode);
if (triggerNode) observer.observe(triggerNode);
return () => observer.disconnect();
}, [triggerRef]);
const geo = useMemo(
() => buildGeo(sizes.tW, sizes.tH, sizes.cW, sizes.cH, side, align, gap, panelRadius),
[sizes, side, align, gap, panelRadius],
);
geoRef.current = geo;
// Morph the same clip on the goo body and the content, so the whole popover
// oozes as one and the text reveals with it.
const render = useCallback((g: Geo | null, p: number) => {
if (!g || g.layerW === 0) return;
const clip = insetForProgress(g, p);
if (blobRef.current) blobRef.current.style.clipPath = clip;
if (clipRef.current) clipRef.current.style.clipPath = clip;
}, []);
useLayoutEffect(() => {
render(geo, progress.get());
}, [geo, progress, render]);
useMotionValueEvent(progress, "change", (p) => render(geoRef.current, p));
const hoverHandlers =
triggerMode === "hover"
? { onMouseEnter: openHover, onMouseLeave: scheduleClose }
: {};
return (
<>
{/* Goo filter: blur, sharpen the alpha back into solid shapes, then lay
the crisp original on top so blobs merge with liquid edges. */}
<svg aria-hidden width="0" height="0" className="pointer-events-none absolute">
<title>Popover goo filter</title>
<defs>
<filter id={gooId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation={gooStrength} result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* Goo body: static trigger pill + morphing blob, behind the trigger. */}
<div
aria-hidden
className="pointer-events-none absolute z-[-1]"
style={{
left: geo.left,
top: geo.top,
width: geo.layerW,
height: geo.layerH,
filter: reduce ? undefined : `url(#${gooId})`,
}}
>
<div
className="absolute bg-popover"
style={{
left: geo.trigger.x,
top: geo.trigger.y,
width: geo.trigger.w,
height: geo.trigger.h,
borderRadius: geo.trigger.r,
}}
/>
<div
ref={blobRef}
className="absolute inset-0 bg-popover"
style={{ clipPath: insetForProgress(geo, progress.get()) }}
/>
</div>
{/* Content, clipped by the same morph. pointer-events-none so it never
shadows the trigger; the open panel re-enables its own. */}
<div
className="pointer-events-none absolute z-10"
style={{ left: geo.left, top: geo.top, width: geo.layerW, height: geo.layerH }}
>
<div
ref={clipRef}
inert={!open}
className="absolute inset-0"
style={{
clipPath: insetForProgress(geo, progress.get()),
pointerEvents: open ? "auto" : "none",
}}
>
<div
ref={measureRef}
id={contentId}
role="dialog"
{...hoverHandlers}
style={{
position: "absolute",
left: geo.panel.x,
top: geo.panel.y,
transformOrigin: `${ALIGN_ORIGIN[align]} ${side === "bottom" ? "top" : "bottom"}`,
}}
className={cn(
"w-max max-w-[min(92vw,20rem)] p-4 text-popover-foreground outline-none",
className,
)}
>
{children}
</div>
</div>
</div>
</>
);
}
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
setRipples((prev) => [
...prev,
{
id: nextId.current++,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { Check, Loader2, X } from "lucide-react";
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Width is set instantly from the measurer; the parent's single `layout`
// animation smooths the resize (text + icons together) so nothing competes.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
Popover
open?booleanControlled open state.
—defaultOpen?booleanUncontrolled initial open state.
falseonOpenChange?((open: boolean) => void)—trigger?"click" | "hover"How the popover is summoned. Default "click".
clickside?"top" | "bottom"Which side of the trigger the panel oozes out of. Default "bottom".
bottomalign?"start" | "center" | "end"Alignment along the trigger's edge. Default "center".
centersideOffset?numberGap between trigger and panel, in px — the length of the gooey neck. Default 14.
14panelRadius?numberCorner radius of the open panel, in px. Default 16.
16gooStrength?numberBlur radius feeding the goo filter — higher melts more. Default 8.
8className?string—PopoverTrigger
childrenReactElementA single focusable element (e.g. a Button) that opens the popover.
—PopoverContent
className?string—Related components
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.