Popover
Gooey 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, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.
Gooey Popover
popover.tsxComposable Popover, PopoverTrigger, PopoverContent; the panel oozes out of the trigger through an SVG goo filter with a liquid neck, crisp content fading in on top. Click or hover, controlled or uncontrolled.
"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,
type MotionValue,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import {
type HoverGesture,
useHoverGesture,
} from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
type TriggerMode = "click" | "hover";
// This morph needs less bounce than layout motion: too much overshoot makes
// the liquid neck balloon past the final panel edges.
const GOO_OPEN_SPRING = {
type: "spring",
visualDuration: 0.3,
bounce: 0.15,
} as const;
const GOO_CLOSE_SPRING = {
type: "spring",
visualDuration: 0.21,
bounce: 0.15,
} as const;
const HOVER_CLOSE_DELAY = 120;
const CIRCLE_KAPPA = 0.5523;
// `onPointerEnter`/`onPointerLeave` rather than the mouse pair: a tap fires
// compatibility mouseenter/mouseleave that carry no pointerType at all, and
// they are what made the panel flicker open and shut under a finger. The
// gesture pairs the two, so the panel a pen tap opened is not closed again by
// the boundary event that ends the same tap.
function makeHoverHandlers(
hover: HoverGesture,
enter: () => void,
leave: () => void,
) {
return {
onPointerEnter: (event: React.PointerEvent) => {
if (hover.enter(event)) enter();
},
onPointerLeave: (event: React.PointerEvent) => {
if (hover.leave(event)) leave();
},
};
}
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 rectAtProgress(geo: Geo, progress: number): Rect {
const trigger = geo.trigger;
const panel = geo.panel;
return {
x: lerp(trigger.x, panel.x, progress),
y: lerp(trigger.y, panel.y, progress),
w: lerp(trigger.w, panel.w, progress),
h: lerp(trigger.h, panel.h, progress),
r: lerp(trigger.r, panel.r, progress),
};
}
function insetFor(rect: Rect, layerW: number, layerH: number) {
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 roundedRectShape(rect: Rect) {
const radius = Math.max(0, Math.min(rect.r, rect.w / 2, rect.h / 2));
const control = radius * CIRCLE_KAPPA;
const x1 = rect.x;
const y1 = rect.y;
const x2 = rect.x + rect.w;
const y2 = rect.y + rect.h;
const px = (value: number) => `${value.toFixed(3)}px`;
return (
`shape(from ${px(x1 + radius)} ${px(y1)}, ` +
`line to ${px(x2 - radius)} ${px(y1)}, ` +
`curve to ${px(x2)} ${px(y1 + radius)} with ${px(x2 - radius + control)} ${px(y1)} / ${px(x2)} ${px(y1 + radius - control)}, ` +
`line to ${px(x2)} ${px(y2 - radius)}, ` +
`curve to ${px(x2 - radius)} ${px(y2)} with ${px(x2)} ${px(y2 - radius + control)} / ${px(x2 - radius + control)} ${px(y2)}, ` +
`line to ${px(x1 + radius)} ${px(y2)}, ` +
`curve to ${px(x1)} ${px(y2 - radius)} with ${px(x1 + radius - control)} ${px(y2)} / ${px(x1)} ${px(y2 - radius + control)}, ` +
`line to ${px(x1)} ${px(y1 + radius)}, ` +
`curve to ${px(x1 + radius)} ${px(y1)} with ${px(x1)} ${px(y1 + radius - control)} / ${px(x1 + radius - control)} ${px(y1)}, ` +
"close)"
);
}
function clipForProgress(geo: Geo, progress: number, supportsShape: boolean) {
const rect = rectAtProgress(geo, progress);
return supportsShape
? roundedRectShape(rect)
: insetFor(rect, geo.layerW, geo.layerH);
}
function roundedRectPath(rect: Rect) {
const radius = Math.max(0, Math.min(rect.r, rect.w / 2, rect.h / 2));
const n = (value: number) => value.toFixed(3);
const x1 = rect.x;
const y1 = rect.y;
const x2 = rect.x + rect.w;
const y2 = rect.y + rect.h;
const arc = `A${n(radius)} ${n(radius)} 0 0 1`;
// A zero radius makes every arc degenerate to a line, so this also draws
// plain rectangles.
return (
`M${n(x1 + radius)} ${n(y1)}` +
`H${n(x2 - radius)}${arc} ${n(x2)} ${n(y1 + radius)}` +
`V${n(y2 - radius)}${arc} ${n(x2 - radius)} ${n(y2)}` +
`H${n(x1 + radius)}${arc} ${n(x1)} ${n(y2 - radius)}` +
`V${n(y1 + radius)}${arc} ${n(x1 + radius)} ${n(y1)}Z`
);
}
// The goo layer is portalled above the page, so its copy of the trigger pill
// would cover the real trigger's label and focus ring. Punching the trigger
// back out keeps the real one visible and clips the blur to the layer box.
// This is a clip path rather than a CSS mask on purpose: WebKit silently
// ignores `mask: url(#id)` pointing at an SVG <mask> element, which left the
// label hidden behind the goo in Safari.
function triggerCutout(geo: Geo) {
const layer = { x: 0, y: 0, w: geo.layerW, h: geo.layerH, r: 0 };
return `path(evenodd, "${roundedRectPath(layer)} ${roundedRectPath(geo.trigger)}")`;
}
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>;
contentRef: React.MutableRefObject<HTMLDivElement | 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 contentRef = useRef<HTMLDivElement | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rootHover = useHoverGesture();
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 }
: open
? GOO_OPEN_SPRING
: GOO_CLOSE_SPRING,
);
return () => animation.stop();
}, [open, progress, reduce]);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: Escape hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (inPanel) triggerRef.current?.focus();
}, [setOpen]);
// The panel is portalled, so both trees participate in outside detection.
const ignoreContent = useCallback(
(target: Element) => Boolean(contentRef.current?.contains(target)),
[],
);
// A hover trigger opens on tap as well now, so it needs the same outside
// dismissal the click trigger always had. The gesture passes through to
// whatever it landed on, which is the light-dismiss bargain the platform's
// own popovers strike.
useDismiss(open, close, rootRef, { ignore: ignoreContent });
const ctx = useMemo<PopoverContextValue>(
() => ({
open,
setOpen,
toggle,
openHover,
scheduleClose,
triggerMode: trigger,
side,
align,
gap: sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
contentRef,
}),
[
open,
setOpen,
toggle,
openHover,
scheduleClose,
trigger,
side,
align,
sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
],
);
const hoverHandlers =
trigger === "hover"
? makeHoverHandlers(rootHover, openHover, 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");
// What the last gesture on the trigger was, and whether the panel was
// already open when it started. A click reports neither.
const tap = useTapGesture<boolean>();
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 =
<E extends { defaultPrevented?: boolean }>(
name: string,
handler: (event: E) => void,
) =>
(event: E) => {
(childProps[name] as ((e: unknown) => void) | undefined)?.(event);
if (!event.defaultPrevented) handler(event);
};
// Observation, not action. `compose` steps aside for a child that handled
// the event itself, which is right for anything that *does* something — but
// a child preventing the pointerdown default (to hold focus, say) has not
// said the gesture didn't happen. Skipping the record there left the panel
// reading whatever the gesture before it had put in.
const observe =
<E,>(name: string, handler: (event: E) => void) =>
(event: E) => {
(childProps[name] as ((e: unknown) => void) | undefined)?.(event);
handler(event);
};
// The hover trigger keeps its hover path and *adds* a tap one, rather than
// swapping mode on a device that reports a touchscreen: a touchscreen laptop
// has both inputs and the mouse must keep working. A hovering pointer has
// already opened the panel on its way in, and a keyboard press arrives with
// no pointerdown behind it, so only a tap toggles here. Which panel state
// the tap acts on is read from the gesture's start, because a browser that
// focuses the trigger on contact would otherwise open it mid-gesture and let
// the click close it again.
const handlers: Record<string, unknown> =
ctx.triggerMode === "hover"
? {
onFocus: compose("onFocus", ctx.openHover),
onBlur: compose("onBlur", ctx.scheduleClose),
onPointerDown: observe<React.PointerEvent>(
"onPointerDown",
(event) => tap.start(event, ctx.open),
),
onPointerCancel: observe("onPointerCancel", tap.drop),
onKeyDown: observe("onKeyDown", tap.drop),
onClick: compose("onClick", () => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
ctx.setOpen(!gesture.state);
}),
}
: { 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 [portalReady, setPortalReady] = useState(false);
const {
side,
align,
gap,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
contentRef,
open,
triggerMode,
openHover,
scheduleClose,
} = ctx;
const measureRef = contentRef;
const panelHover = useHoverGesture();
const blobRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
const geoRef = useRef<Geo | null>(null);
const supportsShapeRef = useRef(false);
const layout = usePopoverPortalPosition(
triggerRef,
measureRef,
portalReady,
);
useEffect(() => setPortalReady(true), []);
const geo = useMemo(
() =>
buildGeo(
layout?.trigger.width ?? 0,
layout?.trigger.height ?? 0,
layout?.content.width ?? 0,
layout?.content.height ?? 0,
side,
align,
gap,
panelRadius,
),
[layout, side, align, gap, panelRadius],
);
// 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 = clipForProgress(g, p, supportsShapeRef.current);
if (blobRef.current) blobRef.current.style.clipPath = clip;
if (clipRef.current) clipRef.current.style.clipPath = clip;
}, []);
useLayoutEffect(() => {
supportsShapeRef.current =
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports(
"clip-path",
"shape(from 0px 0px, line to 1px 1px, close)",
);
geoRef.current = geo;
render(geo, progress.get());
}, [geo, progress, render]);
useMotionValueEvent(progress, "change", (p) => render(geoRef.current, p));
const hoverHandlers =
triggerMode === "hover"
? makeHoverHandlers(panelHover, openHover, scheduleClose)
: {};
// Match the server and first client render, then attach the portal after
// hydration. This preserves SSR without regenerating the page on the client.
if (!portalReady) return null;
return createPortal(
<div
data-popover-portal=""
className="pointer-events-none fixed left-0 top-0 z-[9999] isolate size-0"
style={{
visibility: layout ? "visible" : "hidden",
transform: `translate3d(${layout?.trigger.left ?? 0}px, ${layout?.trigger.top ?? 0}px, 0)`,
}}
>
{/* 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="absolute">
<title>Popover visual effects</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. */}
<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})`,
clipPath: triggerCutout(geo),
}}
>
<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: clipForProgress(geo, progress.get(), false),
}}
/>
</div>
{/* Content is clipped by the same morph. The portal wrapper stays
pointer-transparent; only the fully open panel accepts interaction. */}
<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: clipForProgress(geo, progress.get(), false),
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>
</div>,
document.body,
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
// 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;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} 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,
type MotionValue,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import {
type HoverGesture,
useHoverGesture,
} from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
type TriggerMode = "click" | "hover";
// This morph needs less bounce than layout motion: too much overshoot makes
// the liquid neck balloon past the final panel edges.
const GOO_OPEN_SPRING = {
type: "spring",
visualDuration: 0.3,
bounce: 0.15,
} as const;
const GOO_CLOSE_SPRING = {
type: "spring",
visualDuration: 0.21,
bounce: 0.15,
} as const;
const HOVER_CLOSE_DELAY = 120;
const CIRCLE_KAPPA = 0.5523;
// `onPointerEnter`/`onPointerLeave` rather than the mouse pair: a tap fires
// compatibility mouseenter/mouseleave that carry no pointerType at all, and
// they are what made the panel flicker open and shut under a finger. The
// gesture pairs the two, so the panel a pen tap opened is not closed again by
// the boundary event that ends the same tap.
function makeHoverHandlers(
hover: HoverGesture,
enter: () => void,
leave: () => void,
) {
return {
onPointerEnter: (event: React.PointerEvent) => {
if (hover.enter(event)) enter();
},
onPointerLeave: (event: React.PointerEvent) => {
if (hover.leave(event)) leave();
},
};
}
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 rectAtProgress(geo: Geo, progress: number): Rect {
const trigger = geo.trigger;
const panel = geo.panel;
return {
x: lerp(trigger.x, panel.x, progress),
y: lerp(trigger.y, panel.y, progress),
w: lerp(trigger.w, panel.w, progress),
h: lerp(trigger.h, panel.h, progress),
r: lerp(trigger.r, panel.r, progress),
};
}
function insetFor(rect: Rect, layerW: number, layerH: number) {
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 roundedRectShape(rect: Rect) {
const radius = Math.max(0, Math.min(rect.r, rect.w / 2, rect.h / 2));
const control = radius * CIRCLE_KAPPA;
const x1 = rect.x;
const y1 = rect.y;
const x2 = rect.x + rect.w;
const y2 = rect.y + rect.h;
const px = (value: number) => `${value.toFixed(3)}px`;
return (
`shape(from ${px(x1 + radius)} ${px(y1)}, ` +
`line to ${px(x2 - radius)} ${px(y1)}, ` +
`curve to ${px(x2)} ${px(y1 + radius)} with ${px(x2 - radius + control)} ${px(y1)} / ${px(x2)} ${px(y1 + radius - control)}, ` +
`line to ${px(x2)} ${px(y2 - radius)}, ` +
`curve to ${px(x2 - radius)} ${px(y2)} with ${px(x2)} ${px(y2 - radius + control)} / ${px(x2 - radius + control)} ${px(y2)}, ` +
`line to ${px(x1 + radius)} ${px(y2)}, ` +
`curve to ${px(x1)} ${px(y2 - radius)} with ${px(x1 + radius - control)} ${px(y2)} / ${px(x1)} ${px(y2 - radius + control)}, ` +
`line to ${px(x1)} ${px(y1 + radius)}, ` +
`curve to ${px(x1 + radius)} ${px(y1)} with ${px(x1)} ${px(y1 + radius - control)} / ${px(x1 + radius - control)} ${px(y1)}, ` +
"close)"
);
}
function clipForProgress(geo: Geo, progress: number, supportsShape: boolean) {
const rect = rectAtProgress(geo, progress);
return supportsShape
? roundedRectShape(rect)
: insetFor(rect, geo.layerW, geo.layerH);
}
function roundedRectPath(rect: Rect) {
const radius = Math.max(0, Math.min(rect.r, rect.w / 2, rect.h / 2));
const n = (value: number) => value.toFixed(3);
const x1 = rect.x;
const y1 = rect.y;
const x2 = rect.x + rect.w;
const y2 = rect.y + rect.h;
const arc = `A${n(radius)} ${n(radius)} 0 0 1`;
// A zero radius makes every arc degenerate to a line, so this also draws
// plain rectangles.
return (
`M${n(x1 + radius)} ${n(y1)}` +
`H${n(x2 - radius)}${arc} ${n(x2)} ${n(y1 + radius)}` +
`V${n(y2 - radius)}${arc} ${n(x2 - radius)} ${n(y2)}` +
`H${n(x1 + radius)}${arc} ${n(x1)} ${n(y2 - radius)}` +
`V${n(y1 + radius)}${arc} ${n(x1 + radius)} ${n(y1)}Z`
);
}
// The goo layer is portalled above the page, so its copy of the trigger pill
// would cover the real trigger's label and focus ring. Punching the trigger
// back out keeps the real one visible and clips the blur to the layer box.
// This is a clip path rather than a CSS mask on purpose: WebKit silently
// ignores `mask: url(#id)` pointing at an SVG <mask> element, which left the
// label hidden behind the goo in Safari.
function triggerCutout(geo: Geo) {
const layer = { x: 0, y: 0, w: geo.layerW, h: geo.layerH, r: 0 };
return `path(evenodd, "${roundedRectPath(layer)} ${roundedRectPath(geo.trigger)}")`;
}
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>;
contentRef: React.MutableRefObject<HTMLDivElement | 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 contentRef = useRef<HTMLDivElement | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rootHover = useHoverGesture();
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 }
: open
? GOO_OPEN_SPRING
: GOO_CLOSE_SPRING,
);
return () => animation.stop();
}, [open, progress, reduce]);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: Escape hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (inPanel) triggerRef.current?.focus();
}, [setOpen]);
// The panel is portalled, so both trees participate in outside detection.
const ignoreContent = useCallback(
(target: Element) => Boolean(contentRef.current?.contains(target)),
[],
);
// A hover trigger opens on tap as well now, so it needs the same outside
// dismissal the click trigger always had. The gesture passes through to
// whatever it landed on, which is the light-dismiss bargain the platform's
// own popovers strike.
useDismiss(open, close, rootRef, { ignore: ignoreContent });
const ctx = useMemo<PopoverContextValue>(
() => ({
open,
setOpen,
toggle,
openHover,
scheduleClose,
triggerMode: trigger,
side,
align,
gap: sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
contentRef,
}),
[
open,
setOpen,
toggle,
openHover,
scheduleClose,
trigger,
side,
align,
sideOffset,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
],
);
const hoverHandlers =
trigger === "hover"
? makeHoverHandlers(rootHover, openHover, 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");
// What the last gesture on the trigger was, and whether the panel was
// already open when it started. A click reports neither.
const tap = useTapGesture<boolean>();
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 =
<E extends { defaultPrevented?: boolean }>(
name: string,
handler: (event: E) => void,
) =>
(event: E) => {
(childProps[name] as ((e: unknown) => void) | undefined)?.(event);
if (!event.defaultPrevented) handler(event);
};
// Observation, not action. `compose` steps aside for a child that handled
// the event itself, which is right for anything that *does* something — but
// a child preventing the pointerdown default (to hold focus, say) has not
// said the gesture didn't happen. Skipping the record there left the panel
// reading whatever the gesture before it had put in.
const observe =
<E,>(name: string, handler: (event: E) => void) =>
(event: E) => {
(childProps[name] as ((e: unknown) => void) | undefined)?.(event);
handler(event);
};
// The hover trigger keeps its hover path and *adds* a tap one, rather than
// swapping mode on a device that reports a touchscreen: a touchscreen laptop
// has both inputs and the mouse must keep working. A hovering pointer has
// already opened the panel on its way in, and a keyboard press arrives with
// no pointerdown behind it, so only a tap toggles here. Which panel state
// the tap acts on is read from the gesture's start, because a browser that
// focuses the trigger on contact would otherwise open it mid-gesture and let
// the click close it again.
const handlers: Record<string, unknown> =
ctx.triggerMode === "hover"
? {
onFocus: compose("onFocus", ctx.openHover),
onBlur: compose("onBlur", ctx.scheduleClose),
onPointerDown: observe<React.PointerEvent>(
"onPointerDown",
(event) => tap.start(event, ctx.open),
),
onPointerCancel: observe("onPointerCancel", tap.drop),
onKeyDown: observe("onKeyDown", tap.drop),
onClick: compose("onClick", () => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
ctx.setOpen(!gesture.state);
}),
}
: { 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 [portalReady, setPortalReady] = useState(false);
const {
side,
align,
gap,
panelRadius,
gooStrength,
reduce,
gooId,
contentId,
progress,
triggerRef,
contentRef,
open,
triggerMode,
openHover,
scheduleClose,
} = ctx;
const measureRef = contentRef;
const panelHover = useHoverGesture();
const blobRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
const geoRef = useRef<Geo | null>(null);
const supportsShapeRef = useRef(false);
const layout = usePopoverPortalPosition(
triggerRef,
measureRef,
portalReady,
);
useEffect(() => setPortalReady(true), []);
const geo = useMemo(
() =>
buildGeo(
layout?.trigger.width ?? 0,
layout?.trigger.height ?? 0,
layout?.content.width ?? 0,
layout?.content.height ?? 0,
side,
align,
gap,
panelRadius,
),
[layout, side, align, gap, panelRadius],
);
// 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 = clipForProgress(g, p, supportsShapeRef.current);
if (blobRef.current) blobRef.current.style.clipPath = clip;
if (clipRef.current) clipRef.current.style.clipPath = clip;
}, []);
useLayoutEffect(() => {
supportsShapeRef.current =
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports(
"clip-path",
"shape(from 0px 0px, line to 1px 1px, close)",
);
geoRef.current = geo;
render(geo, progress.get());
}, [geo, progress, render]);
useMotionValueEvent(progress, "change", (p) => render(geoRef.current, p));
const hoverHandlers =
triggerMode === "hover"
? makeHoverHandlers(panelHover, openHover, scheduleClose)
: {};
// Match the server and first client render, then attach the portal after
// hydration. This preserves SSR without regenerating the page on the client.
if (!portalReady) return null;
return createPortal(
<div
data-popover-portal=""
className="pointer-events-none fixed left-0 top-0 z-[9999] isolate size-0"
style={{
visibility: layout ? "visible" : "hidden",
transform: `translate3d(${layout?.trigger.left ?? 0}px, ${layout?.trigger.top ?? 0}px, 0)`,
}}
>
{/* 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="absolute">
<title>Popover visual effects</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. */}
<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})`,
clipPath: triggerCutout(geo),
}}
>
<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: clipForProgress(geo, progress.get(), false),
}}
/>
</div>
{/* Content is clipped by the same morph. The portal wrapper stays
pointer-transparent; only the fully open panel accepts interaction. */}
<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: clipForProgress(geo, progress.get(), false),
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>
</div>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
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;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
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;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
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.05, 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>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"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 { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} 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;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
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"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: 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" | "end" | "center"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—Morph Popover
popover-morph.tsxComposable MorphPopover, MorphPopoverTrigger, MorphPopoverContent; the panel is laid out full size but clipped to the corner nearest the trigger, then unclips as one piece — a single-surface morph with a drop-shadow that hugs the shape. Side/align aware, controlled or uncontrolled.
"use client";
import { ChevronDown, Copy, Pencil, Share2, Trash2 } from "lucide-react";
import { useState } from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
const ACTIONS = [
{ icon: Pencil, label: "Edit" },
{ icon: Copy, label: "Duplicate" },
{ icon: Share2, label: "Share" },
{ icon: Trash2, label: "Delete" },
];
export function MorphPopoverPreview() {
const [open, setOpen] = useState(false);
return (
<MorphPopover open={open} onOpenChange={setOpen}>
<MorphPopoverTrigger>
<button
type="button"
className="inline-flex h-10 items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-medium text-foreground outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring"
>
Options
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align="start" className="w-48 p-1.5">
{ACTIONS.map(({ icon: Icon, label }) => (
<button
key={label}
type="button"
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted"
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</button>
))}
</MorphPopoverContent>
</MorphPopover>
);
}
"use client";
// beui.dev/components/motion/popover
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
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 toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
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;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/popover
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
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 toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
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;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
API Reference
MorphPopover
open?booleanControlled open state.
—defaultOpen?booleanUncontrolled initial open state.
falseonOpenChange?((open: boolean) => void)—className?string—MorphPopoverContent
side?"top" | "bottom"bottomalign?"start" | "end"endsideOffset?numberGap between trigger and panel, in px. Default 8.
8radius?numberPanel corner radius, in px. Default 16.
16className?string—Updated