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