{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"breadcrumb","type":"registry:component","title":"Animated Breadcrumb","description":"Composable breadcrumb navigation with soft path transitions, a hoverable overflow dropdown for long trails, custom separators, and router-link support.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/breadcrumb.tsx","type":"registry:component","target":"@components/motion/breadcrumb.tsx","content":"\"use client\";\n// beui.dev/components/motion/breadcrumb\n\nimport { ChevronRight, Ellipsis } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useIsPresent,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport {\n  Children,\n  forwardRef,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n  useId,\n  type ComponentPropsWithRef,\n  type ReactElement,\n} from \"react\";\nimport { MorphPopover, MorphPopoverContent, MorphPopoverTrigger } from \"@/components/motion/popover-morph\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type BreadcrumbProps = ComponentPropsWithRef<\"nav\">;\n\n/** A navigation landmark. Keep it mounted while the route changes. */\nexport function Breadcrumb({ className, children, ...props }: BreadcrumbProps) {\n  const id = useId();\n  return (\n    <nav aria-label=\"Breadcrumb\" {...props} className={cn(\"min-w-0\", className)}>\n      <LayoutGroup id={id}>{children}</LayoutGroup>\n    </nav>\n  );\n}\n\nexport type BreadcrumbListProps = ComponentPropsWithRef<\"ol\"> & {\n  /** Maximum visible slots, including the ellipsis. Minimum 3; Infinity disables collapsing. */\n  maxItems?: number;\n  /** Accessible label for the hidden ancestor disclosure. */\n  overflowLabel?: string;\n};\n\n/** Pass keyed BreadcrumbItems directly so entering and leaving routes animate. */\nexport function BreadcrumbList({ className, children, maxItems = 4, overflowLabel = \"Show hidden paths\", ...props }: BreadcrumbListProps) {\n  const items = Children.toArray(children);\n  const limit = Number.isFinite(maxItems) ? Math.max(3, Math.floor(maxItems)) : 4;\n  const collapse = maxItems !== Infinity && items.length > limit;\n  const tailCount = limit - 2;\n  const visible = collapse ? [\n    items[0],\n    <BreadcrumbItem key=\"breadcrumb-overflow\">\n      <BreadcrumbSeparator />\n      <BreadcrumbEllipsis label={overflowLabel}>\n        {items.slice(1, -tailCount)}\n      </BreadcrumbEllipsis>\n    </BreadcrumbItem>,\n    ...items.slice(-tailCount),\n  ] : items;\n  return (\n    <ol\n      {...props}\n      className={cn(\"relative flex flex-wrap items-center gap-x-1 gap-y-1 text-sm\", className)}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">{visible}</AnimatePresence>\n    </ol>\n  );\n}\n\nexport type BreadcrumbItemProps = HTMLMotionProps<\"li\">;\n\n/** Use a stable route key; put its optional separator inside this item. */\nexport const BreadcrumbItem = forwardRef<HTMLLIElement, BreadcrumbItemProps>(\n  function BreadcrumbItem({ className, style, children, ...props }, ref) {\n    const reduce = useReducedMotion();\n    const present = useIsPresent();\n    const itemRef = useRef<HTMLLIElement>(null);\n    useLayoutEffect(() => {\n      const item = itemRef.current;\n      if (!item || !present) return;\n      const measure = () => {\n        // popLayout snapshots offsetWidth (integer pixels). Retain the exact\n        // width so a fractional-pixel loss cannot wrap the final character.\n        item.style.setProperty(\"--breadcrumb-exit-width\", `${item.getBoundingClientRect().width}px`);\n      };\n      measure();\n      const observer = new ResizeObserver(measure);\n      observer.observe(item);\n      return () => observer.disconnect();\n    }, [present]);\n    const hidden = { opacity: 0, y: reduce ? 0 : 6 };\n    return (\n      <motion.li\n        ref={(node) => {\n          itemRef.current = node;\n          if (typeof ref === \"function\") return ref(node);\n          if (ref) ref.current = node;\n        }}\n        layout={reduce ? false : \"position\"}\n        initial={hidden}\n        animate={{ opacity: 1, y: 0 }}\n        exit={hidden}\n        transition={{ duration: 0.2, ease: EASE_OUT, layout: SPRING_LAYOUT }}\n        {...props}\n        inert={!present}\n        aria-hidden={!present || undefined}\n        style={{\n          ...style,\n          minWidth: present ? style?.minWidth : \"var(--breadcrumb-exit-width)\",\n          pointerEvents: present ? style?.pointerEvents : \"none\",\n        }}\n        className={cn(\"relative inline-flex min-w-0 max-w-full items-center gap-1\", className)}\n      >\n        {children}\n      </motion.li>\n    );\n  },\n);\n\nexport type BreadcrumbLinkProps = ComponentPropsWithRef<\"a\"> & {\n  /** Render your router's Link, spreading these props onto it. */\n  render?: (props: ComponentPropsWithRef<\"a\">) => ReactElement;\n};\n\nexport function BreadcrumbLink({ className, render, ...props }: BreadcrumbLinkProps) {\n  const linkProps = {\n    ...props,\n    className: cn(\n      \"inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-muted-foreground transition-colors duration-150 hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&>svg]:size-3.5 [&>svg]:shrink-0\",\n      className,\n    ),\n  };\n  return render ? render(linkProps) : <a {...linkProps} />;\n}\n\nexport type BreadcrumbPageProps = ComponentPropsWithRef<\"span\">;\n\nexport function BreadcrumbPage({ className, children, ...props }: BreadcrumbPageProps) {\n  return (\n    <span\n      {...props}\n      aria-current=\"page\"\n      className={cn(\n        \"relative isolate inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground [overflow-wrap:anywhere] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-3.5 [&>svg]:shrink-0\",\n        className,\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport type BreadcrumbSeparatorProps = ComponentPropsWithRef<\"span\">;\n\n/** Decorative separator, placed inside the following BreadcrumbItem. */\nexport function BreadcrumbSeparator({ className, children, ...props }: BreadcrumbSeparatorProps) {\n  return (\n    <span\n      {...props}\n      aria-hidden=\"true\"\n      data-breadcrumb-separator=\"\"\n      className={cn(\"inline-flex shrink-0 items-center text-muted-foreground/50 [&>svg]:size-3.5 rtl:rotate-180\", className)}\n    >\n      {children ?? <ChevronRight />}\n    </span>\n  );\n}\n\n\nexport interface BreadcrumbEllipsisProps {\n  /** Hidden BreadcrumbItems, in path order. */\n  children: ReactNode;\n  className?: string;\n  label?: string;\n}\n\n/** Hover disclosure with click/touch toggle and keyboard access to ancestor links. */\nexport function BreadcrumbEllipsis({ children, className, label = \"Show hidden paths\" }: BreadcrumbEllipsisProps) {\n  const [open, setOpen] = useState(false);\n  const [placement, setPlacement] = useState<{ align: \"start\" | \"end\"; side: \"top\" | \"bottom\"; width: number }>({ align: \"start\", side: \"bottom\", width: 224 });\n  const canHover = useHoverCapable();\n  const present = useIsPresent();\n  const trigger = useRef<HTMLButtonElement>(null);\n  const panel = useRef<HTMLOListElement>(null);\n  const focusOnOpen = useRef(false);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useLayoutEffect(() => {\n    if (!open) return;\n    const update = () => {\n      const rect = trigger.current?.getBoundingClientRect();\n      if (!rect) return;\n      const right = window.innerWidth - rect.left - 8;\n      const left = rect.right - 8;\n      const align = right < 224 && left > right ? \"end\" : \"start\";\n      const below = window.innerHeight - rect.bottom;\n      setPlacement({\n        align,\n        side: below < 280 && rect.top > below ? \"top\" : \"bottom\",\n        width: Math.max(32, Math.min(224, align === \"start\" ? right : left)),\n      });\n    };\n    update();\n    window.addEventListener(\"resize\", update);\n    return () => window.removeEventListener(\"resize\", update);\n  }, [open]);\n\n  const cancelClose = () => {\n    if (closeTimer.current !== null) clearTimeout(closeTimer.current);\n    closeTimer.current = null;\n  };\n  const leave = () => {\n    cancelClose();\n    // Allow the pointer to cross the gap between the trigger and portal.\n    closeTimer.current = setTimeout(() => {\n      if (!panel.current?.contains(document.activeElement) && document.activeElement !== trigger.current) setOpen(false);\n    }, 160);\n  };\n  useEffect(() => () => {\n    if (closeTimer.current !== null) clearTimeout(closeTimer.current);\n  }, []);\n\n  useEffect(() => {\n    if (!open) return;\n    const onFocus = (event: FocusEvent) => {\n      if (event.target instanceof Node && event.target !== trigger.current && !panel.current?.contains(event.target)) setOpen(false);\n    };\n    document.addEventListener(\"focusin\", onFocus);\n    return () => document.removeEventListener(\"focusin\", onFocus);\n  }, [open]);\n\n  return (\n    <MorphPopover open={open && present} onOpenChange={setOpen} className={cn(className)}>\n      <span\n        onPointerEnter={(event) => {\n          cancelClose();\n          if (canHover && event.pointerType === \"mouse\") {\n            focusOnOpen.current = false;\n            setOpen(true);\n          }\n        }}\n        onPointerLeave={leave}\n      >\n        <MorphPopoverTrigger>\n          <button\n            ref={trigger}\n            type=\"button\"\n            aria-label={label}\n            className=\"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            onClick={(event) => { focusOnOpen.current = event.detail === 0; }}\n            onKeyDown={(event) => {\n              if (event.key === \"ArrowDown\") {\n                event.preventDefault();\n                focusOnOpen.current = true;\n                setOpen(true);\n                panel.current?.querySelector<HTMLElement>(\"a[href],button\")?.focus();\n              }\n            }}\n          >\n            <Ellipsis aria-hidden=\"true\" className=\"size-4\" />\n          </button>\n        </MorphPopoverTrigger>\n        <MorphPopoverContent align={placement.align} side={placement.side} radius={10} sideOffset={6} className=\"p-1.5\">\n          <BreadcrumbOverflowPaths\n            ref={panel}\n            style={{ width: placement.width - 14 }}\n            focusOnOpen={focusOnOpen}\n            onPointerEnter={() => { cancelClose(); setOpen(true); }}\n            onPointerLeave={leave}\n            onClick={(event) => {\n              if ((event.target as Element).closest(\"a[href]\")) setOpen(false);\n            }}\n          >\n            {children}\n          </BreadcrumbOverflowPaths>\n        </MorphPopoverContent>\n      </span>\n    </MorphPopover>\n  );\n}\n\nfunction BreadcrumbOverflowPaths({ focusOnOpen, ref, ...props }: ComponentPropsWithRef<\"ol\"> & { focusOnOpen: { current: boolean } }) {\n  const localRef = useRef<HTMLOListElement>(null);\n  useLayoutEffect(() => {\n    if (!focusOnOpen.current) return;\n    const focus = () => localRef.current?.querySelector<HTMLElement>(\"a[href],button\")?.focus();\n    focus();\n    // The portal becomes visible after its parent's layout measurement.\n    const frame = requestAnimationFrame(() => {\n      focus();\n      focusOnOpen.current = false;\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [focusOnOpen]);\n  return (\n    <ol\n      {...props}\n      ref={(node) => {\n        localRef.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) ref.current = node;\n      }}\n      className=\"flex max-h-64 flex-col gap-0.5 overflow-y-auto [&>li]:w-full [&_a]:w-full [&_a]:py-1 [&_a]:[overflow-wrap:anywhere] [&_[data-breadcrumb-separator]]:hidden\"\n    />\n  );\n}\n"},{"path":"components/motion/popover-morph.tsx","type":"registry:component","target":"@components/motion/popover-morph.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  animate,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n};\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\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  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\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\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child?.props?.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)\n    ?.ref;\n  // Register once per actual ref change, not once per open-state render.\n  const mergedRef = useMemo(\n    () => mergeRefs(childRef, ctx.registerTrigger),\n    [childRef, ctx.registerTrigger],\n  );\n  if (!isValidElement(children)) return children;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergedRef,\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipAt(side: Side, align: Align, radius: number, inset: number) {\n  const top = side === \"bottom\" ? \"0%\" : `${inset}%`;\n  const bottom = side === \"bottom\" ? `${inset}%` : \"0%\";\n  const right = align === \"end\" ? \"0%\" : `${inset}%`;\n  const left = align === \"end\" ? `${inset}%` : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  className?: string;\n}\n\nexport function MorphPopoverContent(props: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const [portalReady, setPortalReady] = useState(false);\n  useEffect(() => setPortalReady(true), []);\n  if (!portalReady) return null;\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open && <MorphPopoverSurface {...props} />}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n// Measurement belongs to the mounted portal session: reopening must not start\n// an entrance at the previous session's coordinates before measuring this one.\nfunction MorphPopoverSurface({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [isPresent, safeToRemove] = usePresence();\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    isPresent,\n  );\n\n  const left = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  const top = layout\n    ? side === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : layout.trigger.top - layout.content.height - sideOffset\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { scale: 0.96, transition: SPRING_PANEL },\n        show: { scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipAt(side, align, radius, 92),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipAt(side, align, radius, 0),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n  // Animate the value directly so opacity stays in the inline style throughout\n  // the entrance. A native opacity animation can expose the initial inline 0\n  // for a frame when it finishes, before Motion writes the final value.\n  const opacity = useMotionValue(0);\n  const ready = layout !== null;\n  useEffect(() => {\n    if (!ready) {\n      if (!isPresent) safeToRemove?.();\n      return;\n    }\n    const animation = animate(opacity, isPresent ? 1 : 0, {\n      ...(reduce ? { duration: 0.12 } : SPRING_PANEL),\n      onComplete: () => {\n        if (!isPresent) safeToRemove?.();\n      },\n    });\n    return () => animation.stop();\n  }, [opacity, ready, isPresent, reduce, safeToRemove]);\n\n  return (\n    <motion.div\n      data-morph-popover-portal=\"\"\n      inert={!isPresent}\n      // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n      // clipped shape below (box-shadow would just get clipped away).\n      variants={wrap}\n      initial=\"hidden\"\n      animate={layout ? \"show\" : \"hidden\"}\n      exit=\"hidden\"\n      style={{\n        left,\n        top,\n        opacity,\n        pointerEvents: isPresent ? \"auto\" : \"none\",\n        visibility: layout ? \"visible\" : \"hidden\",\n        transformOrigin: originFor(side, align),\n      }}\n      className=\"fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]\"\n    >\n      <motion.div\n        ref={ctx.contentRef}\n        id={ctx.contentId}\n        role=\"dialog\"\n        aria-labelledby={ctx.triggerId}\n        variants={clip}\n        style={{ borderRadius: radius }}\n        className={cn(\n          \"overflow-hidden border border-border bg-background\",\n          className,\n        )}\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","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\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","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":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","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/motion/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"}]}