From a735d53f7e8ca60bd33146c5188c33dce33a1b58 Mon Sep 17 00:00:00 2001 From: shabirkhan-dev Date: Thu, 2 Jul 2026 00:00:04 -0700 Subject: [PATCH 1/3] feat: add motion popover component Add a new Popover primitive with buttery spring and blur entry, dismissable behavior, and controlled or uncontrolled state support. Wire it into previews and the registry so it ships through the install flow. Co-authored-by: Cursor --- components/motion/popover.tsx | 234 ++++++++++++++++++ components/previews/index.tsx | 3 + .../previews/motion/popover.preview.tsx | 56 +++++ lib/registry.ts | 8 + 4 files changed, 301 insertions(+) create mode 100644 components/motion/popover.tsx create mode 100644 components/previews/motion/popover.preview.tsx diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx new file mode 100644 index 0000000..161cb1d --- /dev/null +++ b/components/motion/popover.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; +import { + cloneElement, + isValidElement, + useEffect, + useId, + useRef, + useState, + 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"; + +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 buildMotionVariants(side: Side): Variants { + const yOffset = side === "top" ? 8 : side === "bottom" ? -8 : 0; + const xOffset = side === "left" ? 8 : side === "right" ? -8 : 0; + + return { + initial: { + opacity: 0, + scale: 0.94, + filter: "blur(8px)", + 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: 0.97, + filter: "blur(4px)", + x: xOffset * 0.45, + y: yOffset * 0.45, + transition: { duration: 0.12, 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; +} + +export function Popover({ + trigger, + children, + open, + defaultOpen = false, + onOpenChange, + side = "bottom", + align = "center", + className, + contentClassName, + sideOffset = 8, + dismissable = true, +}: 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 = (nextOpen: boolean) => { + if (!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }; + + 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); + }; + }, [isOpen, dismissable]); + + if (!isValidElement(trigger)) return null; + + const triggerElement = cloneElement(trigger as ReactElement>, { + "aria-expanded": isOpen, + "aria-haspopup": "dialog", + "aria-controls": contentId, + 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); + + return ( +
+ {triggerElement} + + {isOpen ? ( +
+ + {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..74643d5 --- /dev/null +++ b/components/previews/motion/popover.preview.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { Popover } from "@/components/motion/popover"; + +export function PopoverPreview() { + const [open, setOpen] = useState(false); + + return ( +
+ + Edit profile + + } + > +
+
+

Quick actions

+

+ Keep this profile menu lightweight and keyboard friendly. +

+
+
+ + + +
+
+
+
+ ); +} 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", From 505b80af440586609fd371c842bc6dd4bc7669cf Mon Sep 17 00:00:00 2001 From: shabirkhan-dev Date: Thu, 2 Jul 2026 00:27:48 -0700 Subject: [PATCH 2/3] fix: tighten popover interaction semantics Stabilize popover close handlers with a memoized setter and hook-safe effect dependencies. Also preserve trigger key handlers while adding Escape handling for cleaner accessibility behavior. Co-authored-by: Cursor --- components/motion/popover.tsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx index 161cb1d..ed19741 100644 --- a/components/motion/popover.tsx +++ b/components/motion/popover.tsx @@ -4,10 +4,12 @@ import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion import { cloneElement, isValidElement, + useCallback, useEffect, useId, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, @@ -148,10 +150,13 @@ export function Popover({ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const isOpen = isControlled ? open : uncontrolledOpen; - const setOpen = (nextOpen: boolean) => { - if (!isControlled) setUncontrolledOpen(nextOpen); - onOpenChange?.(nextOpen); - }; + const setOpen = useCallback( + (nextOpen: boolean) => { + if (!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange], + ); useEffect(() => { if (!isOpen || !dismissable) return; @@ -171,7 +176,7 @@ export function Popover({ document.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("keydown", onKeyDown); }; - }, [isOpen, dismissable]); + }, [dismissable, isOpen, setOpen]); if (!isValidElement(trigger)) return null; @@ -179,6 +184,16 @@ export function Popover({ "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; From 5968cdf55153b013ab277ebc87e6896f713d06d9 Mon Sep 17 00:00:00 2001 From: shabirkhan-dev Date: Thu, 2 Jul 2026 00:49:25 -0700 Subject: [PATCH 3/3] feat: refine popover motion styles and demo coverage Add configurable popover animation styles and close-on-content behavior, then expand the preview with production-oriented use cases and cleaner layout. Improve menu hover contrast so action states stay legible on dark surfaces. Co-authored-by: Cursor --- components/motion/popover.tsx | 71 ++++- .../previews/motion/popover.preview.tsx | 270 +++++++++++++++--- 2 files changed, 293 insertions(+), 48 deletions(-) diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx index ed19741..e6ffc1b 100644 --- a/components/motion/popover.tsx +++ b/components/motion/popover.tsx @@ -19,6 +19,7 @@ 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", @@ -73,15 +74,25 @@ const transformOrigin: Record> = { }, }; -function buildMotionVariants(side: Side): Variants { - const yOffset = side === "top" ? 8 : side === "bottom" ? -8 : 0; - const xOffset = side === "left" ? 8 : side === "right" ? -8 : 0; +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: 0.94, - filter: "blur(8px)", + scale: startScale, + filter: `blur(${startBlur}px)`, x: xOffset, y: yOffset, }, @@ -99,7 +110,7 @@ function buildMotionVariants(side: Side): Variants { }, exit: { opacity: 0, - scale: 0.97, + scale: exitScale, filter: "blur(4px)", x: xOffset * 0.45, y: yOffset * 0.45, @@ -108,6 +119,27 @@ function buildMotionVariants(side: Side): Variants { }; } +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 } }, @@ -128,6 +160,12 @@ export interface PopoverProps { 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({ @@ -142,6 +180,9 @@ export function Popover({ contentClassName, sideOffset = 8, dismissable = true, + closeOnContentClick = false, + animationStyle = "spring", + widthClassName = "w-72", }: PopoverProps) { const reduce = useReducedMotion(); const contentId = useId(); @@ -203,7 +244,7 @@ export function Popover({ }, }); - const variants = reduce ? REDUCED_VARIANTS : buildMotionVariants(side); + const variants = reduce ? REDUCED_VARIANTS : buildMotionVariants(side, animationStyle); return (
@@ -234,12 +275,24 @@ export function Popover({ transformOrigin: transformOrigin[side][align], willChange: "transform, opacity, filter", }} + data-state={isOpen ? "open" : "closed"} className={cn( - "w-72 rounded-2xl border border-border bg-popover/90 p-4 text-popover-foreground shadow-2xl backdrop-blur-xl", + "rounded-2xl border border-border/70 bg-popover/88 p-4 text-popover-foreground shadow-2xl backdrop-blur-xl", + widthClassName, contentClassName, )} + onClick={() => { + if (closeOnContentClick) setOpen(false); + }} > - {children} + + {children} +
) : null} diff --git a/components/previews/motion/popover.preview.tsx b/components/previews/motion/popover.preview.tsx index 74643d5..69ac9ed 100644 --- a/components/previews/motion/popover.preview.tsx +++ b/components/previews/motion/popover.preview.tsx @@ -1,56 +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 [open, setOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [placementOpen, setPlacementOpen] = useState(null); return ( -
- +
+
+
+

Action menu

+

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

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

Quick actions

+
+
+

Quick actions

+

+ Keep this profile menu lightweight and keyboard friendly. +

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

Info card

- Keep this profile menu lightweight and keyboard friendly. + 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.

+
- +
); }