{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-menu","type":"registry:component","title":"Animated Context Menu","description":"Composable context-menu primitives with a pointer-origin clip morph, a gliding active row, checkbox and radio choices, keyboard navigation, typeahead, and long-press support.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/context-menu.tsx","type":"registry:component","target":"@components/motion/context-menu.tsx","content":"\"use client\";\n// beui.dev/components/motion/context-menu\n\nimport { Check } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype OpenModality = \"pointer\" | \"keyboard\" | \"touch\";\ntype MenuPoint = { x: number; y: number };\n\nconst VIEWPORT_PADDING = 8;\nconst LONG_PRESS_DELAY = 520;\nconst LONG_PRESS_TOLERANCE = 10;\nconst MORPH_DURATION = 0.3;\n\ntype TriggerElementProps = React.HTMLAttributes<HTMLElement> & {\n  ref?: Ref<HTMLElement>;\n};\n\ninterface ContextMenuContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openAt: (point: MenuPoint, modality: OpenModality) => void;\n  point: MenuPoint;\n  modality: OpenModality;\n  invocation: number;\n  menuId: string;\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n  activeId: string | null;\n  setActiveId: (id: string | null) => void;\n  reduce: boolean;\n}\n\nconst ContextMenuContext = createContext<ContextMenuContextValue | null>(null);\n\nfunction useContextMenuContext(component: string) {\n  const context = useContext(ContextMenuContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <ContextMenu>`);\n  }\n  return context;\n}\n\nfunction assignRef<T>(ref: Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") {\n    ref(value);\n  } else if (ref) {\n    ref.current = value;\n  }\n}\n\nfunction getEnabledItems(container: HTMLElement | null) {\n  if (!container) return [];\n  return Array.from(\n    container.querySelectorAll<HTMLElement>(\n      '[data-context-menu-item=\"true\"]:not([data-disabled=\"true\"])',\n    ),\n  );\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction collapsedClip(\n  origin: MenuPoint,\n  size: { width: number; height: number },\n) {\n  const half = 8;\n  const top = clamp(origin.y - half, 0, size.height);\n  const right = clamp(size.width - origin.x - half, 0, size.width);\n  const bottom = clamp(size.height - origin.y - half, 0, size.height);\n  const left = clamp(origin.x - half, 0, size.width);\n  return `inset(${top}px ${right}px ${bottom}px ${left}px round 10px)`;\n}\n\nexport interface ContextMenuProps {\n  children: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nexport function ContextMenu({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: ContextMenuProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [point, setPoint] = useState<MenuPoint>({ x: 0, y: 0 });\n  const [modality, setModality] = useState<OpenModality>(\"pointer\");\n  const [invocation, setInvocation] = useState(0);\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n  const triggerRef = useRef<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const menuId = useId();\n  const reduce = useReducedMotion() ?? false;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n      if (!next) setActiveId(null);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const openAt = useCallback(\n    (nextPoint: MenuPoint, nextModality: OpenModality) => {\n      setPoint(nextPoint);\n      setModality(nextModality);\n      setInvocation((current) => current + 1);\n      setActiveId(null);\n      setOpen(true);\n    },\n    [setOpen],\n  );\n\n  useEffect(() => {\n    if (!open) return;\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (!contentRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    const onWindowChange = () => setOpen(false);\n\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    window.addEventListener(\"resize\", onWindowChange);\n    window.addEventListener(\"scroll\", onWindowChange);\n    return () => {\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      window.removeEventListener(\"resize\", onWindowChange);\n      window.removeEventListener(\"scroll\", onWindowChange);\n    };\n  }, [open, setOpen]);\n\n  const value = useMemo<ContextMenuContextValue>(\n    () => ({\n      open,\n      setOpen,\n      openAt,\n      point,\n      modality,\n      invocation,\n      menuId,\n      triggerRef,\n      contentRef,\n      activeId,\n      setActiveId,\n      reduce,\n    }),\n    [\n      open,\n      setOpen,\n      openAt,\n      point,\n      modality,\n      invocation,\n      menuId,\n      activeId,\n      reduce,\n    ],\n  );\n\n  return (\n    <ContextMenuContext.Provider value={value}>\n      <div className={cn(\"contents\", className)}>{children}</div>\n    </ContextMenuContext.Provider>\n  );\n}\n\nexport interface ContextMenuTriggerProps {\n  children: ReactElement<TriggerElementProps>;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function ContextMenuTrigger({\n  children,\n  disabled = false,\n  className,\n}: ContextMenuTriggerProps) {\n  const context = useContextMenuContext(\"ContextMenuTrigger\");\n  const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const touchOrigin = useRef<MenuPoint | null>(null);\n\n  const cancelLongPress = useCallback(() => {\n    if (longPressTimer.current) {\n      clearTimeout(longPressTimer.current);\n      longPressTimer.current = null;\n    }\n    touchOrigin.current = null;\n  }, []);\n\n  useEffect(() => cancelLongPress, [cancelLongPress]);\n\n  if (!isValidElement(children)) {\n    throw new Error(\"<ContextMenuTrigger> requires a single React element\");\n  }\n\n  const childProps = children.props;\n  const childRef = children.props.ref;\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {\n    childProps.onPointerDown?.(event);\n    if (event.defaultPrevented || disabled || event.pointerType !== \"touch\") return;\n\n    const origin = { x: event.clientX, y: event.clientY };\n    touchOrigin.current = origin;\n    longPressTimer.current = setTimeout(() => {\n      context.openAt(origin, \"touch\");\n      longPressTimer.current = null;\n      touchOrigin.current = null;\n    }, LONG_PRESS_DELAY);\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {\n    childProps.onPointerMove?.(event);\n    const origin = touchOrigin.current;\n    if (\n      origin &&\n      Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n        LONG_PRESS_TOLERANCE\n    ) {\n      cancelLongPress();\n    }\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {\n    childProps.onKeyDown?.(event);\n    if (event.defaultPrevented || disabled) return;\n    if (event.key !== \"ContextMenu\" && !(event.shiftKey && event.key === \"F10\"))\n      return;\n\n    event.preventDefault();\n    const rect = event.currentTarget.getBoundingClientRect();\n    context.openAt(\n      { x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 },\n      \"keyboard\",\n    );\n  };\n\n  return cloneElement(children, {\n    ref: (node: HTMLElement | null) => {\n      context.triggerRef.current = node;\n      assignRef(childRef, node);\n    },\n    \"aria-controls\": context.open ? context.menuId : undefined,\n    \"aria-haspopup\": \"menu\",\n    \"aria-expanded\": context.open,\n    className: cn(childProps.className, className),\n    onContextMenu: (event: ReactMouseEvent<HTMLElement>) => {\n      childProps.onContextMenu?.(event);\n      if (event.defaultPrevented || disabled) return;\n      event.preventDefault();\n      cancelLongPress();\n      context.openAt({ x: event.clientX, y: event.clientY }, \"pointer\");\n    },\n    onKeyDown,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {\n      childProps.onPointerUp?.(event);\n      cancelLongPress();\n    },\n    onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => {\n      childProps.onPointerCancel?.(event);\n      cancelLongPress();\n    },\n  });\n}\n\nexport interface ContextMenuContentProps {\n  children: ReactNode;\n  className?: string;\n  ariaLabel?: string;\n}\n\nexport function ContextMenuContent({\n  children,\n  className,\n  ariaLabel = \"Context menu\",\n}: ContextMenuContentProps) {\n  const context = useContextMenuContext(\"ContextMenuContent\");\n  const [mounted, setMounted] = useState(false);\n  const [position, setPosition] = useState<MenuPoint>(context.point);\n  const [origin, setOrigin] = useState<MenuPoint>({ x: 0, y: 0 });\n  const [size, setSize] = useState({ width: 0, height: 0 });\n  const [morphReady, setMorphReady] = useState(false);\n  const typeahead = useRef(\"\");\n  const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => setMounted(true), []);\n\n  useLayoutEffect(() => {\n    if (!context.open) {\n      setMorphReady(false);\n      return;\n    }\n    const content = context.contentRef.current;\n    if (!content) return;\n    content.dataset.invocation = String(context.invocation);\n\n    const rect = content.getBoundingClientRect();\n    const left = Math.max(\n      VIEWPORT_PADDING,\n      Math.min(\n        Math.max(context.point.x, VIEWPORT_PADDING),\n        window.innerWidth - rect.width - VIEWPORT_PADDING,\n      ),\n    );\n    const top = Math.max(\n      VIEWPORT_PADDING,\n      Math.min(\n        Math.max(context.point.y, VIEWPORT_PADDING),\n        window.innerHeight - rect.height - VIEWPORT_PADDING,\n      ),\n    );\n\n    setPosition({ x: left, y: top });\n    setSize({ width: rect.width, height: rect.height });\n    setOrigin({\n      x: clamp(context.point.x - left, 12, Math.max(12, rect.width - 12)),\n      y: clamp(context.point.y - top, 12, Math.max(12, rect.height - 12)),\n    });\n    setMorphReady(false);\n\n    if (context.reduce || context.modality === \"keyboard\") {\n      setMorphReady(true);\n      return;\n    }\n\n    // Let the measured collapsed clip paint once before expanding it. Without\n    // this preparation frame, the first invocation can batch both states and\n    // appear at full size without the morph.\n    let openFrame = 0;\n    const prepareFrame = requestAnimationFrame(() => {\n      openFrame = requestAnimationFrame(() => setMorphReady(true));\n    });\n    return () => {\n      cancelAnimationFrame(prepareFrame);\n      cancelAnimationFrame(openFrame);\n    };\n  }, [\n    context.open,\n    context.point,\n    context.contentRef,\n    context.invocation,\n    context.modality,\n    context.reduce,\n  ]);\n\n  useEffect(() => {\n    if (!context.open) return;\n    const frame = requestAnimationFrame(() => {\n      const first = getEnabledItems(context.contentRef.current)[0];\n      first?.focus({ preventScroll: true });\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [context.open, context.contentRef]);\n\n  useEffect(\n    () => () => {\n      if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);\n    },\n    [],\n  );\n\n  const moveFocus = (direction: 1 | -1) => {\n    const items = getEnabledItems(context.contentRef.current);\n    if (items.length === 0) return;\n    const current = items.indexOf(document.activeElement as HTMLElement);\n    const next = current < 0 ? 0 : (current + direction + items.length) % items.length;\n    items[next]?.focus();\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      context.setOpen(false);\n      context.triggerRef.current?.focus();\n      return;\n    }\n    if (event.key === \"Tab\") {\n      context.setOpen(false);\n      return;\n    }\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      moveFocus(event.key === \"ArrowDown\" ? 1 : -1);\n      return;\n    }\n    if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      const items = getEnabledItems(context.contentRef.current);\n      items[event.key === \"Home\" ? 0 : items.length - 1]?.focus();\n      return;\n    }\n    if (\n      event.key.length === 1 &&\n      !event.ctrlKey &&\n      !event.metaKey &&\n      !event.altKey\n    ) {\n      typeahead.current += event.key.toLocaleLowerCase();\n      if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);\n      typeaheadTimer.current = setTimeout(() => {\n        typeahead.current = \"\";\n      }, 500);\n      const match = getEnabledItems(context.contentRef.current).find((item) =>\n        (item.dataset.label ?? item.textContent ?? \"\")\n          .trim()\n          .toLocaleLowerCase()\n          .startsWith(typeahead.current),\n      );\n      match?.focus();\n    }\n  };\n\n  if (!mounted) return null;\n\n  const visualOpen = context.open && morphReady;\n  const clipHidden = collapsedClip(origin, size);\n  const clipShown = \"inset(0px 0px 0px 0px round 12px)\";\n\n  return createPortal(\n    <div\n      data-context-menu-portal=\"\"\n      aria-hidden={!context.open}\n      inert={!context.open}\n      style={{ left: position.x, top: position.y }}\n      className={cn(\n        \"fixed z-[100] [filter:drop-shadow(0_18px_28px_rgba(0,0,0,0.2))]\",\n        context.open ? \"pointer-events-auto\" : \"pointer-events-none\",\n      )}\n    >\n      <motion.div\n        ref={context.contentRef}\n        id={context.menuId}\n        role=\"menu\"\n        aria-label={ariaLabel}\n        data-morph-ready={morphReady ? \"true\" : \"false\"}\n        tabIndex={-1}\n        initial={false}\n        animate={{\n          opacity: visualOpen ? 1 : 0,\n          clipPath:\n            context.reduce || context.modality === \"keyboard\" || visualOpen\n              ? clipShown\n              : clipHidden,\n        }}\n        transition={\n          context.modality === \"keyboard\"\n            ? { duration: 0 }\n            : context.reduce\n              ? { duration: 0.1, ease: EASE_OUT }\n              : {\n                  clipPath: {\n                    duration: MORPH_DURATION,\n                    ease: EASE_OUT,\n                  },\n                  opacity: {\n                    duration: MORPH_DURATION,\n                    ease: EASE_OUT,\n                  },\n                }\n        }\n        onKeyDown={onKeyDown}\n        onContextMenu={(event) => event.preventDefault()}\n        className={cn(\n          \"min-w-56 overflow-hidden rounded-xl border border-border bg-card p-1.5 text-foreground outline-none\",\n          className,\n        )}\n      >\n        {children}\n      </motion.div>\n    </div>,\n    document.body,\n  );\n}\n\ntype ContextMenuItemTone = \"default\" | \"destructive\";\n\nexport interface ContextMenuItemProps {\n  children: ReactNode;\n  onSelect?: () => void;\n  disabled?: boolean;\n  closeOnSelect?: boolean;\n  tone?: ContextMenuItemTone;\n  inset?: boolean;\n  className?: string;\n  textValue?: string;\n}\n\nfunction ContextMenuItemBase({\n  children,\n  onSelect,\n  disabled = false,\n  closeOnSelect = true,\n  tone = \"default\",\n  inset = false,\n  className,\n  textValue,\n  role = \"menuitem\",\n  ariaChecked,\n}: ContextMenuItemProps & {\n  role?: \"menuitem\" | \"menuitemcheckbox\" | \"menuitemradio\";\n  ariaChecked?: boolean;\n}) {\n  const context = useContextMenuContext(\"ContextMenuItem\");\n  const id = useId();\n  const active = context.activeId === id;\n  const checkedProps =\n    role === \"menuitem\" ? {} : { \"aria-checked\": ariaChecked };\n\n  return (\n    <button\n      type=\"button\"\n      id={id}\n      role={role}\n      {...checkedProps}\n      disabled={disabled}\n      data-context-menu-item=\"true\"\n      data-disabled={disabled ? \"true\" : undefined}\n      data-label={textValue}\n      tabIndex={-1}\n      onFocus={() => context.setActiveId(id)}\n      onPointerMove={(event) => {\n        if (!disabled && event.pointerType !== \"touch\") event.currentTarget.focus();\n      }}\n      onClick={() => {\n        if (disabled) return;\n        onSelect?.();\n        if (closeOnSelect) context.setOpen(false);\n      }}\n      className={cn(\n        \"relative isolate flex w-full select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] outline-none\",\n        \"focus-visible:ring-2 focus-visible:ring-foreground/15\",\n        \"disabled:pointer-events-none disabled:opacity-40\",\n        inset && \"pl-8\",\n        tone === \"destructive\" ? \"text-destructive\" : \"text-foreground\",\n        className,\n      )}\n    >\n      {active ? (\n        <motion.span\n          layoutId={`${context.menuId}-active`}\n          className={cn(\n            \"absolute inset-0 -z-10 rounded-lg\",\n            tone === \"destructive\"\n              ? \"bg-destructive/10\"\n              : \"bg-foreground/[0.065]\",\n          )}\n          transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}\n        />\n      ) : null}\n      {children}\n    </button>\n  );\n}\n\nexport function ContextMenuItem(props: ContextMenuItemProps) {\n  return <ContextMenuItemBase {...props} />;\n}\n\nexport interface ContextMenuCheckboxItemProps\n  extends Omit<ContextMenuItemProps, \"onSelect\"> {\n  checked: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n}\n\nexport function ContextMenuCheckboxItem({\n  checked,\n  onCheckedChange,\n  children,\n  ...props\n}: ContextMenuCheckboxItemProps) {\n  const context = useContextMenuContext(\"ContextMenuCheckboxItem\");\n  return (\n    <ContextMenuItemBase\n      {...props}\n      role=\"menuitemcheckbox\"\n      ariaChecked={checked}\n      onSelect={() => onCheckedChange?.(!checked)}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n        <AnimatePresence initial={false}>\n          {checked ? (\n            <motion.span\n              key=\"check\"\n              initial={context.reduce ? false : { opacity: 0, scale: 0.75 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: context.reduce ? 1 : 0.75 }}\n              transition={context.reduce ? { duration: 0.08 } : SPRING_PANEL}\n            >\n              <Check aria-hidden=\"true\" className=\"h-3.5 w-3.5\" strokeWidth={2.4} />\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </span>\n      {children}\n    </ContextMenuItemBase>\n  );\n}\n\ninterface ContextMenuRadioGroupContextValue {\n  value: string;\n  onValueChange?: (value: string) => void;\n}\n\nconst ContextMenuRadioGroupContext =\n  createContext<ContextMenuRadioGroupContextValue | null>(null);\n\nexport interface ContextMenuRadioGroupProps {\n  value: string;\n  onValueChange?: (value: string) => void;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ContextMenuRadioGroup({\n  value,\n  onValueChange,\n  children,\n  className,\n}: ContextMenuRadioGroupProps) {\n  const context = useMemo(\n    () => ({ value, onValueChange }),\n    [value, onValueChange],\n  );\n  return (\n    <ContextMenuRadioGroupContext.Provider value={context}>\n      <div className={className}>{children}</div>\n    </ContextMenuRadioGroupContext.Provider>\n  );\n}\n\nexport interface ContextMenuRadioItemProps\n  extends Omit<ContextMenuItemProps, \"onSelect\"> {\n  value: string;\n}\n\nexport function ContextMenuRadioItem({\n  value,\n  children,\n  ...props\n}: ContextMenuRadioItemProps) {\n  const group = useContext(ContextMenuRadioGroupContext);\n  if (!group) {\n    throw new Error(\n      \"ContextMenuRadioItem must be used within <ContextMenuRadioGroup>\",\n    );\n  }\n  const checked = group.value === value;\n  return (\n    <ContextMenuItemBase\n      {...props}\n      role=\"menuitemradio\"\n      ariaChecked={checked}\n      onSelect={() => group.onValueChange?.(value)}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n        <span\n          className={cn(\n            \"h-1.5 w-1.5 rounded-full bg-current transition-opacity\",\n            checked ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </span>\n      {children}\n    </ContextMenuItemBase>\n  );\n}\n\nexport interface ContextMenuLabelProps {\n  children: ReactNode;\n  inset?: boolean;\n  className?: string;\n}\n\nexport function ContextMenuLabel({\n  children,\n  inset = false,\n  className,\n}: ContextMenuLabelProps) {\n  return (\n    <div\n      className={cn(\n        \"px-2.5 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground\",\n        inset && \"pl-8\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface ContextMenuSeparatorProps {\n  className?: string;\n}\n\nexport function ContextMenuSeparator({\n  className,\n}: ContextMenuSeparatorProps) {\n  return (\n    <hr className={cn(\"-mx-1 my-1 h-px border-0 bg-border\", className)} />\n  );\n}\n\nexport interface ContextMenuShortcutProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function ContextMenuShortcut({\n  children,\n  className,\n}: ContextMenuShortcutProps) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"ml-auto pl-4 text-[10px] font-medium tracking-wide text-muted-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </span>\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"},{"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"}]}