{"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, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.","category":"motion","source_url":"https://ui-lab-ten.vercel.app/r/popover/raw","detail_url":"https://ui-lab-ten.vercel.app/r/popover","raw_url":"https://ui-lab-ten.vercel.app/r/popover/raw","page_url":"https://ui-lab-ten.vercel.app/components/motion/popover","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["../magnetic","./base","./magnetic","./stateful","@/components/motion/button","@/components/motion/popover","@/lib/ease","@/lib/hooks/use-hover-capable","@/lib/utils"],"files":[{"path":"components/motion/popover.tsx","type":"component","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/motion/popover\n\nimport {\n  animate,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  type MotionValue,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type KeyboardEventHandler,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"center\" | \"end\";\ntype TriggerMode = \"click\" | \"hover\";\n\nconst GOO_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.32,\n  bounce: 0.28,\n} as const;\nconst HOVER_CLOSE_DELAY = 120;\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\ninterface Rect {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  r: number;\n}\ninterface Geo {\n  layerW: number;\n  layerH: number;\n  left: number;\n  top: number;\n  trigger: Rect;\n  panel: Rect;\n}\n\n// Trigger rect and panel rect in a shared local coordinate box.\nfunction buildGeo(\n  tW: number,\n  tH: number,\n  cW: number,\n  cH: number,\n  side: Side,\n  align: Align,\n  gap: number,\n  panelRadius: number,\n): Geo {\n  const py = side === \"bottom\" ? tH + gap : -(gap + cH);\n  const px = align === \"start\" ? 0 : align === \"end\" ? tW - cW : (tW - cW) / 2;\n\n  const left = Math.min(0, px);\n  const top = Math.min(0, py);\n  const layerW = Math.max(tW, px + cW) - left;\n  const layerH = Math.max(tH, py + cH) - top;\n\n  const triggerRadius = Math.min(tH / 2, panelRadius);\n\n  return {\n    layerW,\n    layerH,\n    left,\n    top,\n    trigger: { x: -left, y: -top, w: tW, h: tH, r: triggerRadius },\n    panel: { x: px - left, y: py - top, w: cW, h: cH, r: panelRadius },\n  };\n}\n\nfunction insetFor(rect: Rect, layerW: number, layerH: number): string {\n  const top = rect.y;\n  const right = layerW - (rect.x + rect.w);\n  const bottom = layerH - (rect.y + rect.h);\n  const left = rect.x;\n  return `inset(${top}px ${right}px ${bottom}px ${left}px round ${rect.r}px)`;\n}\n\nfunction insetForProgress(geo: Geo, p: number): string {\n  const t = geo.trigger;\n  const pn = geo.panel;\n  const rect: Rect = {\n    x: lerp(t.x, pn.x, p),\n    y: lerp(t.y, pn.y, p),\n    w: lerp(t.w, pn.w, p),\n    h: lerp(t.h, pn.h, p),\n    r: lerp(t.r, pn.r, p),\n  };\n  return insetFor(rect, geo.layerW, geo.layerH);\n}\n\ninterface PopoverContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  openHover: () => void;\n  scheduleClose: () => void;\n  triggerMode: TriggerMode;\n  side: Side;\n  align: Align;\n  gap: number;\n  panelRadius: number;\n  gooStrength: number;\n  reduce: boolean;\n  gooId: string;\n  contentId: string;\n  progress: MotionValue<number>;\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n}\n\nconst PopoverContext = createContext<PopoverContextValue | null>(null);\n\nfunction usePopoverContext(component: string) {\n  const ctx = useContext(PopoverContext);\n  if (!ctx) throw new Error(`${component} must be used within <Popover>`);\n  return ctx;\n}\n\nexport interface PopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** How the popover is summoned. Default \"click\". */\n  trigger?: TriggerMode;\n  /** Which side of the trigger the panel oozes out of. Default \"bottom\". */\n  side?: Side;\n  /** Alignment along the trigger's edge. Default \"center\". */\n  align?: Align;\n  /** Gap between trigger and panel, in px — the length of the gooey neck. Default 14. */\n  sideOffset?: number;\n  /** Corner radius of the open panel, in px. Default 16. */\n  panelRadius?: number;\n  /** Blur radius feeding the goo filter — higher melts more. Default 8. */\n  gooStrength?: number;\n  className?: string;\n}\n\nexport function Popover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  trigger = \"click\",\n  side = \"bottom\",\n  align = \"center\",\n  sideOffset = 14,\n  panelRadius = 16,\n  gooStrength = 8,\n  className,\n}: PopoverProps) {\n  const reduce = useReducedMotion() ?? false;\n  const gooId = useId().replace(/:/g, \"\");\n  const contentId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLElement | null>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const progress = useMotionValue(defaultOpen ? 1 : 0);\n\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const cancelClose = useCallback(() => {\n    if (closeTimer.current) {\n      clearTimeout(closeTimer.current);\n      closeTimer.current = null;\n    }\n  }, []);\n\n  const openHover = useCallback(() => {\n    cancelClose();\n    setOpen(true);\n  }, [cancelClose, setOpen]);\n\n  const scheduleClose = useCallback(() => {\n    cancelClose();\n    closeTimer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY);\n  }, [cancelClose, setOpen]);\n\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  useEffect(() => () => cancelClose(), [cancelClose]);\n\n  useEffect(() => {\n    const animation = animate(\n      progress,\n      open ? 1 : 0,\n      reduce ? { duration: 0 } : GOO_SPRING,\n    );\n    return () => animation.stop();\n  }, [open, progress, reduce]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    // Trigger and panel share rootRef, so moving between them isn't \"outside\".\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    if (trigger === \"click\") window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen, trigger]);\n\n  const ctx = useMemo<PopoverContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      openHover,\n      scheduleClose,\n      triggerMode: trigger,\n      side,\n      align,\n      gap: sideOffset,\n      panelRadius,\n      gooStrength,\n      reduce,\n      gooId,\n      contentId,\n      progress,\n      triggerRef,\n    }),\n    [\n      open,\n      setOpen,\n      toggle,\n      openHover,\n      scheduleClose,\n      trigger,\n      side,\n      align,\n      sideOffset,\n      panelRadius,\n      gooStrength,\n      reduce,\n      gooId,\n      contentId,\n      progress,\n    ],\n  );\n\n  const hoverHandlers =\n    trigger === \"hover\"\n      ? { onMouseEnter: openHover, onMouseLeave: scheduleClose }\n      : {};\n\n  return (\n    <PopoverContext.Provider value={ctx}>\n      <div\n        ref={rootRef}\n        className={cn(\"relative inline-flex isolate\", className)}\n        {...hoverHandlers}\n      >\n        {children}\n      </div>\n    </PopoverContext.Provider>\n  );\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\nexport interface PopoverTriggerProps {\n  /** A single focusable element (e.g. a Button) that opens the popover. */\n  children: ReactElement;\n}\n\nexport function PopoverTrigger({ children }: PopoverTriggerProps) {\n  const ctx = usePopoverContext(\"PopoverTrigger\");\n\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childProps = child.props;\n  const childRef = (childProps as { ref?: Ref<HTMLElement> }).ref;\n\n  const compose =\n    (name: string, handler: () => void) =>\n    (event: { defaultPrevented?: boolean }) => {\n      (childProps[name] as ((e: unknown) => void) | undefined)?.(event);\n      if (!event.defaultPrevented) handler();\n    };\n\n  const handlers: Record<string, unknown> =\n    ctx.triggerMode === \"hover\"\n      ? {\n          onFocus: compose(\"onFocus\", ctx.openHover),\n          onBlur: compose(\"onBlur\", ctx.scheduleClose),\n        }\n      : { onClick: compose(\"onClick\", ctx.toggle) };\n\n  return cloneElement(child, {\n    ...handlers,\n    ref: mergeRefs(childRef, (node: HTMLElement | null) => {\n      ctx.triggerRef.current = node;\n    }),\n    // Above the goo layer (z-[-1]) so the neck reads behind it.\n    className: cn(\"relative z-0\", childProps.className as string | undefined),\n    \"aria-haspopup\": childProps[\"aria-haspopup\"] ?? \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n    \"data-state\": ctx.open ? \"open\" : \"closed\",\n  });\n}\n\nconst ALIGN_ORIGIN: Record<Align, string> = {\n  start: \"left\",\n  center: \"center\",\n  end: \"right\",\n};\n\nexport interface PopoverContentProps {\n  children: ReactNode;\n  className?: string;\n  role?: \"dialog\" | \"menu\";\n  \"aria-label\"?: string;\n  onKeyDown?: KeyboardEventHandler<HTMLDivElement>;\n}\n\nexport function PopoverContent({\n  children,\n  className,\n  role = \"dialog\",\n  \"aria-label\": ariaLabel,\n  onKeyDown,\n}: PopoverContentProps) {\n  const ctx = usePopoverContext(\"PopoverContent\");\n  const {\n    side,\n    align,\n    gap,\n    panelRadius,\n    gooStrength,\n    reduce,\n    gooId,\n    contentId,\n    progress,\n    triggerRef,\n    open,\n    triggerMode,\n    openHover,\n    scheduleClose,\n  } = ctx;\n\n  const measureRef = useRef<HTMLDivElement>(null);\n  const blobRef = useRef<HTMLDivElement>(null);\n  const clipRef = useRef<HTMLDivElement>(null);\n  const geoRef = useRef<Geo | null>(null);\n\n  const [sizes, setSizes] = useState({ tW: 0, tH: 0, cW: 0, cH: 0 });\n\n  useLayoutEffect(() => {\n    const triggerNode = triggerRef.current;\n    const contentNode = measureRef.current;\n    if (!contentNode) return;\n\n    const measure = () => {\n      const tW = triggerNode?.offsetWidth ?? 0;\n      const tH = triggerNode?.offsetHeight ?? 0;\n      const cW = contentNode.offsetWidth;\n      const cH = contentNode.offsetHeight;\n      setSizes((prev) =>\n        prev.tW === tW && prev.tH === tH && prev.cW === cW && prev.cH === cH\n          ? prev\n          : { tW, tH, cW, cH },\n      );\n    };\n    measure();\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(contentNode);\n    if (triggerNode) observer.observe(triggerNode);\n    return () => observer.disconnect();\n  }, [triggerRef]);\n\n  const geo = useMemo(\n    () =>\n      buildGeo(\n        sizes.tW,\n        sizes.tH,\n        sizes.cW,\n        sizes.cH,\n        side,\n        align,\n        gap,\n        panelRadius,\n      ),\n    [sizes, side, align, gap, panelRadius],\n  );\n  geoRef.current = geo;\n\n  // Morph the same clip on the goo body and the content, so the whole popover\n  // oozes as one and the text reveals with it.\n  const render = useCallback((g: Geo | null, p: number) => {\n    if (!g || g.layerW === 0) return;\n    const clip = insetForProgress(g, p);\n    if (blobRef.current) blobRef.current.style.clipPath = clip;\n    if (clipRef.current) clipRef.current.style.clipPath = clip;\n  }, []);\n\n  useLayoutEffect(() => {\n    render(geo, progress.get());\n  }, [geo, progress, render]);\n\n  useMotionValueEvent(progress, \"change\", (p) => render(geoRef.current, p));\n\n  const hoverHandlers =\n    triggerMode === \"hover\"\n      ? { onMouseEnter: openHover, onMouseLeave: scheduleClose }\n      : {};\n\n  return (\n    <>\n      {/* Goo filter: blur, sharpen the alpha back into solid shapes, then lay\n          the crisp original on top so blobs merge with liquid edges. */}\n      <svg\n        aria-hidden\n        width=\"0\"\n        height=\"0\"\n        className=\"pointer-events-none absolute\"\n      >\n        <title>Popover goo filter</title>\n        <defs>\n          <filter id={gooId} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n            <feGaussianBlur\n              in=\"SourceGraphic\"\n              stdDeviation={gooStrength}\n              result=\"blur\"\n            />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\"\n              result=\"goo\"\n            />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n        </defs>\n      </svg>\n\n      {/* Goo body: static trigger pill + morphing blob, behind the trigger. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute z-[-1]\"\n        style={{\n          left: geo.left,\n          top: geo.top,\n          width: geo.layerW,\n          height: geo.layerH,\n          filter: reduce ? undefined : `url(#${gooId})`,\n        }}\n      >\n        <div\n          className=\"absolute bg-popover\"\n          style={{\n            left: geo.trigger.x,\n            top: geo.trigger.y,\n            width: geo.trigger.w,\n            height: geo.trigger.h,\n            borderRadius: geo.trigger.r,\n          }}\n        />\n        <div\n          ref={blobRef}\n          className=\"absolute inset-0 bg-popover\"\n          style={{ clipPath: insetForProgress(geo, progress.get()) }}\n        />\n      </div>\n\n      {/* Content, clipped by the same morph. pointer-events-none so it never\n          shadows the trigger; the open panel re-enables its own. */}\n      <div\n        className=\"pointer-events-none absolute z-10\"\n        style={{\n          left: geo.left,\n          top: geo.top,\n          width: geo.layerW,\n          height: geo.layerH,\n        }}\n      >\n        <div\n          ref={clipRef}\n          inert={!open}\n          className=\"absolute inset-0\"\n          style={{\n            clipPath: insetForProgress(geo, progress.get()),\n            pointerEvents: open ? \"auto\" : \"none\",\n          }}\n        >\n          {/* biome-ignore lint/a11y: The runtime role defaults to dialog and callers can supply another appropriate popup role with its handlers. */}\n          <div\n            ref={measureRef}\n            id={contentId}\n            role={role}\n            aria-label={ariaLabel}\n            onKeyDown={onKeyDown}\n            {...hoverHandlers}\n            style={{\n              position: \"absolute\",\n              left: geo.panel.x,\n              top: geo.panel.y,\n              transformOrigin: `${ALIGN_ORIGIN[align]} ${side === \"bottom\" ? \"top\" : \"bottom\"}`,\n            }}\n            className={cn(\n              \"w-max max-w-[min(92vw,20rem)] p-4 text-popover-foreground outline-none\",\n              className,\n            )}\n          >\n            {children}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/popover.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Button } from \"@/components/motion/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/motion/popover\";\n\nexport function PopoverPreview() {\n  return (\n    <div className=\"flex flex-wrap items-center justify-center gap-4\">\n      <Popover side=\"bottom\" align=\"start\">\n        <PopoverTrigger>\n          <Button variant=\"secondary\">Edit profile</Button>\n        </PopoverTrigger>\n        <PopoverContent className=\"w-72\">\n          <p className=\"text-sm font-medium text-foreground\">Dimensions</p>\n          <p className=\"mt-1 text-xs text-muted-foreground\">\n            Set the width and height for the layer.\n          </p>\n          <div className=\"mt-3 flex flex-col gap-2\">\n            <label className=\"flex items-center justify-between gap-3 text-sm\">\n              <span className=\"text-muted-foreground\">Width</span>\n              <input\n                defaultValue=\"100%\"\n                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\"\n              />\n            </label>\n            <label className=\"flex items-center justify-between gap-3 text-sm\">\n              <span className=\"text-muted-foreground\">Height</span>\n              <input\n                defaultValue=\"auto\"\n                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\"\n              />\n            </label>\n          </div>\n        </PopoverContent>\n      </Popover>\n\n      <Popover trigger=\"hover\" side=\"top\">\n        <PopoverTrigger>\n          <Button variant=\"outline\">Hover me</Button>\n        </PopoverTrigger>\n        <PopoverContent className=\"w-56\">\n          <p className=\"text-sm text-foreground\">\n            Opens on hover, with a grace window so you can move into the panel.\n          </p>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"util","content":"export { Button } from \"./base\";\nexport type { ButtonProps, ButtonVariant, ButtonSize } from \"./base\";\n\nexport { StatefulButton } from \"./stateful\";\nexport type { StatefulButtonProps, ButtonState } from \"./stateful\";\n\nexport { MagneticButton } from \"./magnetic\";\nexport type { MagneticButtonProps } from \"./magnetic\";\n"},{"path":"components/motion/button/base.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id: nextId.current++,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  forwardRef,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Width is set instantly from the measurer; the parent's single `layout`\n  // animation smooths the resize (text + icons together) so nothing competes.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}