diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx new file mode 100644 index 0000000..e6ffc1b --- /dev/null +++ b/components/motion/popover.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; +import { + cloneElement, + isValidElement, + useCallback, + useEffect, + useId, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, +} from "react"; +import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +type Side = "top" | "right" | "bottom" | "left"; +type Align = "start" | "center" | "end"; +type AnimationStyle = "spring" | "soft" | "drift"; + +const sideClass: Record = { + top: "bottom-full", + right: "left-full", + bottom: "top-full", + left: "right-full", +}; + +const alignClass: Record> = { + top: { + start: "left-0", + center: "left-1/2 -translate-x-1/2", + end: "right-0", + }, + bottom: { + start: "left-0", + center: "left-1/2 -translate-x-1/2", + end: "right-0", + }, + left: { + start: "top-0", + center: "top-1/2 -translate-y-1/2", + end: "bottom-0", + }, + right: { + start: "top-0", + center: "top-1/2 -translate-y-1/2", + end: "bottom-0", + }, +}; + +const transformOrigin: Record> = { + top: { + start: "left bottom", + center: "center bottom", + end: "right bottom", + }, + right: { + start: "left top", + center: "left center", + end: "left bottom", + }, + bottom: { + start: "left top", + center: "center top", + end: "right top", + }, + left: { + start: "right top", + center: "right center", + end: "right bottom", + }, +}; + +function axisOffset(side: Side, distance: number) { + return { + yOffset: side === "top" ? distance : side === "bottom" ? -distance : 0, + xOffset: side === "left" ? distance : side === "right" ? -distance : 0, + }; +} + +function buildMotionVariants(side: Side, animationStyle: AnimationStyle): Variants { + const offsetDistance = animationStyle === "drift" ? 12 : animationStyle === "soft" ? 6 : 8; + const { xOffset, yOffset } = axisOffset(side, offsetDistance); + const startScale = animationStyle === "soft" ? 0.97 : animationStyle === "drift" ? 0.99 : 0.94; + const startBlur = animationStyle === "soft" ? 4 : animationStyle === "drift" ? 6 : 8; + const exitScale = animationStyle === "drift" ? 0.99 : 0.97; + + return { + initial: { + opacity: 0, + scale: startScale, + filter: `blur(${startBlur}px)`, + x: xOffset, + y: yOffset, + }, + animate: { + opacity: 1, + scale: 1, + filter: "blur(0px)", + x: 0, + y: 0, + transition: { + ...SPRING_PANEL, + opacity: { duration: 0.18, ease: EASE_OUT }, + filter: { duration: 0.22, ease: EASE_OUT }, + }, + }, + exit: { + opacity: 0, + scale: exitScale, + filter: "blur(4px)", + x: xOffset * 0.45, + y: yOffset * 0.45, + transition: { duration: 0.12, ease: EASE_OUT }, + }, + }; +} + +const CONTENT_VARIANTS: Variants = { + initial: { opacity: 0, y: 4 }, + animate: { + opacity: 1, + y: 0, + transition: { + duration: 0.18, + ease: EASE_OUT, + delay: 0.03, + }, + }, + exit: { + opacity: 0, + y: 2, + transition: { + duration: 0.1, + ease: EASE_OUT, + }, + }, +}; + +const REDUCED_VARIANTS: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } }, + exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }, +}; + +export interface PopoverProps { + trigger: ReactElement; + children: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + side?: Side; + align?: Align; + className?: string; + contentClassName?: string; + /** Additional offset in pixels, applied from the trigger edge. */ + sideOffset?: number; + /** Close when pressing escape or clicking outside. Default true. */ + dismissable?: boolean; + /** Close when panel content is clicked (useful for action menus). Default false. */ + closeOnContentClick?: boolean; + /** Motion personality for the panel shell. Default "spring". */ + animationStyle?: AnimationStyle; + /** Optional fixed width utility classes for the panel (default w-72). */ + widthClassName?: string; +} + +export function Popover({ + trigger, + children, + open, + defaultOpen = false, + onOpenChange, + side = "bottom", + align = "center", + className, + contentClassName, + sideOffset = 8, + dismissable = true, + closeOnContentClick = false, + animationStyle = "spring", + widthClassName = "w-72", +}: PopoverProps) { + const reduce = useReducedMotion(); + const contentId = useId(); + const wrapperRef = useRef(null); + const isControlled = open !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isOpen = isControlled ? open : uncontrolledOpen; + + const setOpen = useCallback( + (nextOpen: boolean) => { + if (!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange], + ); + + useEffect(() => { + if (!isOpen || !dismissable) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (!wrapperRef.current?.contains(target)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [dismissable, isOpen, setOpen]); + + if (!isValidElement(trigger)) return null; + + const triggerElement = cloneElement(trigger as ReactElement>, { + "aria-expanded": isOpen, + "aria-haspopup": "dialog", + "aria-controls": contentId, + onKeyDown: (event: ReactKeyboardEvent) => { + const triggerProps = trigger.props as { + onKeyDown?: (ev: ReactKeyboardEvent) => void; + }; + triggerProps.onKeyDown?.(event); + if (event.defaultPrevented) return; + if (event.key === "Escape" && isOpen) { + setOpen(false); + } + }, + onClick: (event: ReactMouseEvent) => { + const triggerProps = trigger.props as { + onClick?: (ev: ReactMouseEvent) => void; + }; + triggerProps.onClick?.(event); + if (!event.defaultPrevented) setOpen(!isOpen); + }, + }); + + const variants = reduce ? REDUCED_VARIANTS : buildMotionVariants(side, animationStyle); + + return ( +
+ {triggerElement} + + {isOpen ? ( +
+ { + if (closeOnContentClick) setOpen(false); + }} + > + + {children} + + +
+ ) : null} +
+
+ ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 9e92be8..555904a 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -148,6 +148,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..69ac9ed --- /dev/null +++ b/components/previews/motion/popover.preview.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useState } from "react"; +import { Bell, ChevronDown, Shield, Sparkles } from "lucide-react"; +import { Popover } from "@/components/motion/popover"; + +export function PopoverPreview() { + const [menuOpen, setMenuOpen] = useState(false); + const [placementOpen, setPlacementOpen] = useState(null); + + return ( +
+
+
+
+

Action menu

+

+ Controlled open state with a compact profile action list. +

+
+ + Edit profile + + + } + > +
+
+

Quick actions

+

+ Keep this profile menu lightweight and keyboard friendly. +

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

Info card

+

+ Uncontrolled popover for quick CTA and summary details. +

+
+ + + Upgrade preview + + } + > +
+

Pro workspace

+

+ Enable advanced analytics, unlimited history, and priority sync. +

+ +
+
+
+
+ +
+
+
+

Reactions picker

+

+ Compact utility popover with a faster, drift-style entrance. +

+
+ + Add reaction + + } + > +
+

React quickly

+
+ {["🔥", "👏", "🎉", "💯", "😍", "🤝", "🚀", "🫡"].map((emoji) => ( + + ))} +
+
+
+
+ +
+
+

Security hint

+

+ Contextual helper anchored to the right with restrained motion. +

+
+ + + Security tips + + } + > +
+

Protect your workspace

+
    +
  • Use passkeys for passwordless sign-in.
  • +
  • Enable 2FA for all collaborators.
  • +
  • Rotate personal access tokens monthly.
  • +
+
+
+
+
+ +
+
+

Placement examples

+

Top, right and left panel directions.

+
+
+ setPlacementOpen(next ? "top" : null)} + side="top" + align="center" + widthClassName="w-56" + trigger={ + + } + > +

You have 3 unread notifications.

+
+ + setPlacementOpen(next ? "right" : null)} + side="right" + align="center" + widthClassName="w-56" + trigger={ + + } + > +

2-factor authentication is enabled.

+
+ + setPlacementOpen(next ? "left" : null)} + side="left" + align="center" + widthClassName="w-56" + trigger={ + + } + > +

Session expires in 14 minutes.

+
+
+
+
+ ); +} diff --git a/lib/registry.ts b/lib/registry.ts index 41cb29a..8c69f6c 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -183,6 +183,14 @@ 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: + "Click-triggered floating panel with spring scale-in, blur entry and controlled/uncontrolled open state.", + file: "components/motion/popover.tsx", + badge: "new", + }, { slug: "morphing-modal", name: "Morphing Modal",