diff --git a/AGENTS.md b/AGENTS.md index 54ddb12..f27d5ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Before building a new component, check this list. If it exists, import it. If it | `shared-layout-bg` | `components/motion/shared-layout-bg.tsx` | Pill that glides between hovered items via shared layout | | `dock` | `components/motion/dock.tsx` | macOS-style dock with grouped actions and gliding active pill | | `tooltip` | `components/motion/tooltip.tsx` | Hover/focus tooltip with blur enter/exit and spring spawn | +| `popover` | `components/motion/popover.tsx` | Composable gooey popover (`Popover`, `PopoverTrigger`, `PopoverContent`); panel oozes out of the trigger via an SVG goo filter (liquid neck that stretches/pinches) with crisp content fading in on top. Inline-anchored, click or hover trigger, controlled/uncontrolled | | `morphing-modal` | `components/motion/morphing-modal.tsx` | Panel that morphs height across inner views with blur cross-fade | | `text-reveal` | `components/motion/text-reveal.tsx` | Word or character reveal with spring slide-up and blur | | `text-shimmer` | `components/motion/text-shimmer.tsx` | Gradient sweep across text for loading or emphasis | diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx new file mode 100644 index 0000000..574d3c3 --- /dev/null +++ b/components/motion/popover.tsx @@ -0,0 +1,515 @@ +"use client"; + +import { + animate, + useMotionValue, + useMotionValueEvent, + useReducedMotion, + type MotionValue, +} from "motion/react"; +import { + cloneElement, + createContext, + isValidElement, + type ReactElement, + type ReactNode, + type Ref, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; + +type Side = "top" | "bottom"; +type Align = "start" | "center" | "end"; +type TriggerMode = "click" | "hover"; + +const GOO_SPRING = { type: "spring", visualDuration: 0.32, bounce: 0.28 } as const; +const HOVER_CLOSE_DELAY = 120; + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +interface Rect { + x: number; + y: number; + w: number; + h: number; + r: number; +} +interface Geo { + layerW: number; + layerH: number; + left: number; + top: number; + trigger: Rect; + panel: Rect; +} + +// Trigger rect and panel rect in a shared local coordinate box. +function buildGeo( + tW: number, + tH: number, + cW: number, + cH: number, + side: Side, + align: Align, + gap: number, + panelRadius: number, +): Geo { + const py = side === "bottom" ? tH + gap : -(gap + cH); + const px = align === "start" ? 0 : align === "end" ? tW - cW : (tW - cW) / 2; + + const left = Math.min(0, px); + const top = Math.min(0, py); + const layerW = Math.max(tW, px + cW) - left; + const layerH = Math.max(tH, py + cH) - top; + + const triggerRadius = Math.min(tH / 2, panelRadius); + + return { + layerW, + layerH, + left, + top, + trigger: { x: -left, y: -top, w: tW, h: tH, r: triggerRadius }, + panel: { x: px - left, y: py - top, w: cW, h: cH, r: panelRadius }, + }; +} + +function insetFor(rect: Rect, layerW: number, layerH: number): string { + const top = rect.y; + const right = layerW - (rect.x + rect.w); + const bottom = layerH - (rect.y + rect.h); + const left = rect.x; + return `inset(${top}px ${right}px ${bottom}px ${left}px round ${rect.r}px)`; +} + +function insetForProgress(geo: Geo, p: number): string { + const t = geo.trigger; + const pn = geo.panel; + const rect: Rect = { + x: lerp(t.x, pn.x, p), + y: lerp(t.y, pn.y, p), + w: lerp(t.w, pn.w, p), + h: lerp(t.h, pn.h, p), + r: lerp(t.r, pn.r, p), + }; + return insetFor(rect, geo.layerW, geo.layerH); +} + +interface PopoverContextValue { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; + openHover: () => void; + scheduleClose: () => void; + triggerMode: TriggerMode; + side: Side; + align: Align; + gap: number; + panelRadius: number; + gooStrength: number; + reduce: boolean; + gooId: string; + contentId: string; + progress: MotionValue; + triggerRef: React.MutableRefObject; +} + +const PopoverContext = createContext(null); + +function usePopoverContext(component: string) { + const ctx = useContext(PopoverContext); + if (!ctx) throw new Error(`${component} must be used within `); + 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(null); + const triggerRef = useRef(null); + const closeTimer = useRef | 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( + () => ({ + 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 ( + +
+ {children} +
+
+ ); +} + +function mergeRefs(...refs: Array | 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).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>; + const childProps = child.props; + const childRef = (childProps as { ref?: Ref }).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 = + ctx.triggerMode === "hover" + ? { + onFocus: compose("onFocus", ctx.openHover), + onBlur: compose("onBlur", ctx.scheduleClose), + } + : { onClick: compose("onClick", ctx.toggle) }; + + return cloneElement(child, { + ...handlers, + ref: mergeRefs(childRef, (node: HTMLElement | null) => { + ctx.triggerRef.current = node; + }), + // Above the goo layer (z-[-1]) so the neck reads behind it. + className: cn("relative z-0", childProps.className as string | undefined), + "aria-haspopup": "dialog", + "aria-expanded": ctx.open, + "aria-controls": ctx.open ? ctx.contentId : undefined, + "data-state": ctx.open ? "open" : "closed", + }); +} + +const ALIGN_ORIGIN: Record = { + start: "left", + center: "center", + end: "right", +}; + +export interface PopoverContentProps { + children: ReactNode; + className?: string; +} + +export function PopoverContent({ children, className }: PopoverContentProps) { + const ctx = usePopoverContext("PopoverContent"); + const { + side, + align, + gap, + panelRadius, + gooStrength, + reduce, + gooId, + contentId, + progress, + triggerRef, + open, + triggerMode, + openHover, + scheduleClose, + } = ctx; + + const measureRef = useRef(null); + const blobRef = useRef(null); + const clipRef = useRef(null); + const geoRef = useRef(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. */} + + Popover goo filter + + + + + + + + + + {/* Goo body: static trigger pill + morphing blob, behind the trigger. */} +
+
+
+
+ + {/* Content, clipped by the same morph. pointer-events-none so it never + shadows the trigger; the open panel re-enables its own. */} +
+
+ +
+
+ + ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 6d12c81..4fa5e2c 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -151,6 +151,9 @@ export const previews: Record = { "motion/tooltip": dynamic(() => import("./motion/tooltip.preview").then((m) => m.TooltipPreview), ), + "motion/popover": dynamic(() => + import("./motion/popover.preview").then((m) => m.PopoverPreview), + ), "motion/morphing-modal": dynamic(() => import("./motion/morphing-modal.preview").then((m) => m.MorphingModalPreview), ), diff --git a/components/previews/motion/popover.preview.tsx b/components/previews/motion/popover.preview.tsx new file mode 100644 index 0000000..6cc2e58 --- /dev/null +++ b/components/previews/motion/popover.preview.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Button } from "@/components/motion/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/motion/popover"; + +export function PopoverPreview() { + return ( +
+ + + + + +

Dimensions

+

+ Set the width and height for the layer. +

+
+ + +
+
+
+ + + + + + +

+ Opens on hover, with a grace window so you can move into the panel. +

+
+
+
+ ); +} diff --git a/lib/registry.ts b/lib/registry.ts index 120ffd6..7d5e0d8 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -183,6 +183,22 @@ export const registry: CategoryEntry[] = [ description: "Hover or focus tooltip with blur enter/exit and spring spawn.", file: "components/motion/tooltip.tsx", }, + { + slug: "popover", + name: "Popover", + description: + "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. Click or hover trigger, controlled or uncontrolled.", + file: "components/motion/popover.tsx", + badge: "new", + keywords: [ + "react popover", + "gooey popover", + "animated popover", + "svg goo filter", + "metaball popover", + "hover popover react", + ], + }, { slug: "morphing-modal", name: "Morphing Modal",