{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "liquid-fab",
  "type": "registry:component",
  "title": "Liquid FAB",
  "description": "A round action button that squeezes out its actions as droplets: each one stretches away, pinches off into its own button and gets drawn back in when you close it, with labels as tooltips and full keyboard support.",
  "categories": [
    "motion",
    "Liquid"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/devl/liquid-fab.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { type LucideIcon, PlusIcon } from \"lucide-react\";\nimport {\n  type AnimationPlaybackControls,\n  animate,\n  type MotionValue,\n  motion,\n  motionValue,\n  useReducedMotion,\n  type ValueAnimationTransition,\n} from \"motion/react\";\nimport { type KeyboardEvent, type MouseEvent, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LiquidFabAction {\n  id: string;\n  label: string;\n  icon: LucideIcon;\n  onSelect?: () => void;\n}\n\nexport type LiquidFabDirection = \"up\" | \"up-left\" | \"up-right\";\n\nexport interface LiquidFabProps {\n  actions: LiquidFabAction[];\n  direction?: LiquidFabDirection;\n  label?: string;\n  size?: number;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nconst ARCS: Record<LiquidFabDirection, [number, number]> = {\n  up: [-150, -30],\n  \"up-left\": [-90, -180],\n  \"up-right\": [-90, 0],\n};\n\nconst settle = [0.23, 1, 0.32, 1] as const;\n\ninterface Drop {\n  x: MotionValue<number>;\n  y: MotionValue<number>;\n  neckX: MotionValue<number>;\n  neckY: MotionValue<number>;\n  scale: MotionValue<number>;\n  stretchX: MotionValue<number>;\n  stretchY: MotionValue<number>;\n  opacity: MotionValue<number>;\n}\n\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction layout(count: number, direction: LiquidFabDirection, size: number, actionSize: number) {\n  const [from, to] = ARCS[direction];\n  const step = count > 1 ? (to - from) / (count - 1) : 0;\n  const spacing = Math.abs(step) * (Math.PI / 180);\n  const reach = Math.max(size * 1.5, spacing ? (actionSize + 14) / (2 * Math.sin(spacing / 2)) : 0);\n  return Array.from({ length: count }, (_, i) => {\n    const angle = count > 1 ? from + step * i : (from + to) / 2;\n    const rad = angle * (Math.PI / 180);\n    const x = Math.cos(rad) * reach;\n    const y = Math.sin(rad) * reach;\n    const side: \"left\" | \"right\" | \"top\" = Math.cos(rad) < -0.4 ? \"left\" : Math.cos(rad) > 0.4 ? \"right\" : \"top\";\n    return { x, y, angle, side };\n  });\n}\n\nexport function LiquidFab({\n  actions,\n  direction = \"up\",\n  label = \"Quick actions\",\n  size = 56,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: LiquidFabProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const filterId = `liquid-fab-${uid}`;\n  const groupId = `liquid-fab-group-${uid}`;\n  const reduce = useSafeReducedMotion();\n  const [openState, setOpenState] = useState(defaultOpen);\n  const open = openProp ?? openState;\n  const root = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const focusFirst = useRef(false);\n  const mainScale = useMemo(() => motionValue(1), []);\n\n  const actionSize = Math.round(size * 0.78);\n  const blur = size * 0.125;\n  const spots = useMemo(() => layout(actions.length, direction, size, actionSize), [actions.length, direction, size, actionSize]);\n  const drops = useMemo<Drop[]>(\n    () =>\n      Array.from({ length: actions.length }, () => ({\n        x: motionValue(0),\n        y: motionValue(0),\n        neckX: motionValue(0),\n        neckY: motionValue(0),\n        scale: motionValue(0.5),\n        stretchX: motionValue(1),\n        stretchY: motionValue(1),\n        opacity: motionValue(0),\n      })),\n    [actions.length],\n  );\n\n  const margin = actionSize * 0.7 + 8;\n  const minX = Math.min(-size / 2, ...spots.map((s) => s.x - margin));\n  const maxX = Math.max(size / 2, ...spots.map((s) => s.x + margin));\n  const minY = Math.min(-size / 2, ...spots.map((s) => s.y - margin));\n  const maxY = Math.max(size / 2, ...spots.map((s) => s.y + margin));\n  const boxW = maxX - minX;\n  const boxH = maxY - minY;\n  const pad = blur * 3;\n\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setOpenState(next);\n    onOpenChange?.(next);\n  };\n\n  useEffect(() => {\n    const count = drops.length;\n    const runs: AnimationPlaybackControls[] = [];\n    const run = (value: MotionValue<number>, to: number | number[], options: ValueAnimationTransition<number>) => {\n      runs.push(animate(value, to, options));\n    };\n    drops.forEach((d, i) => {\n      const spot = spots[i];\n      if (!spot) return;\n      if (reduce) {\n        if (open) {\n          d.x.set(spot.x);\n          d.y.set(spot.y);\n          d.scale.set(1);\n        }\n        run(d.opacity, open ? 1 : 0, { duration: 0.16 });\n        return;\n      }\n      if (open) {\n        const delay = i * 0.04;\n        run(d.x, spot.x, { type: \"spring\", stiffness: 460, damping: 26, delay });\n        run(d.y, spot.y, { type: \"spring\", stiffness: 460, damping: 26, delay });\n        run(d.scale, 1, { duration: 0.3, ease: settle, delay });\n        run(d.stretchX, [d.stretchX.get(), 1.32, 0.94, 1], { duration: 0.55, times: [0, 0.3, 0.65, 1], delay });\n        run(d.stretchY, [d.stretchY.get(), 0.8, 1.05, 1], { duration: 0.55, times: [0, 0.3, 0.65, 1], delay });\n        run(d.neckX, [0, spot.x * 0.42, 0], { duration: 0.42, times: [0, 0.4, 1], ease: \"easeOut\", delay });\n        run(d.neckY, [0, spot.y * 0.42, 0], { duration: 0.42, times: [0, 0.4, 1], ease: \"easeOut\", delay });\n        run(d.opacity, 1, { duration: 0.16, delay: delay + 0.1 });\n      } else {\n        const delay = (count - 1 - i) * 0.03;\n        run(d.opacity, 0, { duration: 0.1 });\n        run(d.x, 0, { type: \"spring\", stiffness: 420, damping: 34, delay });\n        run(d.y, 0, { type: \"spring\", stiffness: 420, damping: 34, delay });\n        run(d.scale, 0.5, { duration: 0.34, ease: [0.4, 0, 0.2, 1], delay });\n        run(d.stretchX, [d.stretchX.get(), 1.22, 1], { duration: 0.36, delay });\n        run(d.stretchY, [d.stretchY.get(), 0.86, 1], { duration: 0.36, delay });\n        run(d.neckX, [0, d.x.get() * 0.5, 0], { duration: 0.34, times: [0, 0.35, 1], delay });\n        run(d.neckY, [0, d.y.get() * 0.5, 0], { duration: 0.34, times: [0, 0.35, 1], delay });\n      }\n    });\n    if (!reduce) {\n      run(mainScale, open ? [mainScale.get(), 0.9, 1.04, 1] : [mainScale.get(), 1, 1.05, 1], {\n        duration: 0.42,\n        times: [0, 0.3, 0.65, 1],\n        delay: open ? 0 : 0.12,\n      });\n    }\n    return () => {\n      for (const r of runs) r.stop();\n    };\n  }, [open, reduce, drops, spots, mainScale]);\n\n  useEffect(() => {\n    if (!open) return;\n    if (focusFirst.current) {\n      focusFirst.current = false;\n      buttons.current[0]?.focus();\n    }\n    const onDown = (e: PointerEvent) => {\n      if (!root.current?.contains(e.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  });\n\n  const toggle = (e: MouseEvent<HTMLButtonElement>) => {\n    if (!open && e.detail === 0) focusFirst.current = true;\n    setOpen(!open);\n  };\n\n  const choose = (action: LiquidFabAction) => {\n    action.onSelect?.();\n    setOpen(false);\n    trigger.current?.focus();\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\" && open) {\n      e.stopPropagation();\n      setOpen(false);\n      trigger.current?.focus();\n      return;\n    }\n    if (!open) return;\n    const keys = [\"ArrowUp\", \"ArrowDown\", \"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"];\n    if (!keys.includes(e.key)) return;\n    const list = buttons.current.filter(Boolean) as HTMLButtonElement[];\n    const at = list.indexOf(document.activeElement as HTMLButtonElement);\n    e.preventDefault();\n    if (e.key === \"Home\") return list[0]?.focus();\n    if (e.key === \"End\") return list[list.length - 1]?.focus();\n    const forward = e.key === \"ArrowUp\" || (direction === \"up-left\" ? e.key === \"ArrowLeft\" : e.key === \"ArrowRight\");\n    const next = at === -1 ? 0 : (at + (forward ? 1 : -1) + list.length) % list.length;\n    list[next]?.focus();\n  };\n\n  return (\n    <TooltipProvider delay={250}>\n      <div\n        className={cn(\n          \"relative inline-block shrink-0 text-primary-foreground [--liquid-fab:var(--color-primary)]\",\n          className,\n        )}\n        onBlur={(e) => {\n          if (open && !root.current?.contains(e.relatedTarget as Node)) setOpen(false);\n        }}\n        onKeyDown={onKeyDown}\n        ref={root}\n        style={{ width: size, height: size }}\n      >\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute drop-shadow-[0_2px_3px_rgb(0_0_0/0.12)] dark:drop-shadow-[0_2px_4px_rgb(0_0_0/0.4)]\"\n          style={{ left: size / 2 + minX, top: size / 2 + minY, width: boxW, height: boxH }}\n        >\n          <svg className=\"absolute size-0\">\n            <filter\n              colorInterpolationFilters=\"sRGB\"\n              height={`${((boxH + pad * 2) / boxH) * 100}%`}\n              id={filterId}\n              width={`${((boxW + pad * 2) / boxW) * 100}%`}\n              x={`${(-pad / boxW) * 100}%`}\n              y={`${(-pad / boxH) * 100}%`}\n            >\n              <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation={blur} />\n              <feColorMatrix in=\"blur\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\" />\n            </filter>\n          </svg>\n          <span className=\"absolute inset-0\" style={{ filter: `url(#${filterId})` }}>\n            {drops.map((d, i) => {\n              const spot = spots[i];\n              if (!spot || reduce) return null;\n              const neck = actionSize * 0.5;\n              return (\n                <span key={actions[i]?.id ?? i}>\n                  <motion.span\n                    className=\"absolute rounded-full bg-(--liquid-fab)\"\n                    style={{\n                      left: -minX - neck / 2,\n                      top: -minY - neck / 2,\n                      width: neck,\n                      height: neck,\n                      x: d.neckX,\n                      y: d.neckY,\n                    }}\n                  />\n                  <motion.span\n                    className=\"absolute\"\n                    style={{\n                      left: -minX - actionSize / 2,\n                      top: -minY - actionSize / 2,\n                      width: actionSize,\n                      height: actionSize,\n                      x: d.x,\n                      y: d.y,\n                      rotate: spot.angle,\n                      scale: d.scale,\n                    }}\n                  >\n                    <motion.span\n                      className=\"absolute inset-0 rounded-full bg-(--liquid-fab)\"\n                      style={{ scaleX: d.stretchX, scaleY: d.stretchY }}\n                    />\n                  </motion.span>\n                </span>\n              );\n            })}\n            <motion.span\n              className=\"absolute rounded-full bg-(--liquid-fab)\"\n              style={{ left: -minX - size / 2, top: -minY - size / 2, width: size, height: size, scale: mainScale }}\n            />\n          </span>\n        </span>\n\n        <div\n          aria-label={label}\n          className={cn(\"absolute inset-0\", !open && \"pointer-events-none\")}\n          id={groupId}\n          inert={!open}\n          role=\"group\"\n        >\n          {actions.map((action, i) => {\n            const d = drops[i];\n            const spot = spots[i];\n            if (!d || !spot) return null;\n            const Icon = action.icon;\n            return (\n              <Tooltip key={action.id}>\n                <TooltipTrigger\n                  render={\n                    <motion.button\n                      aria-label={action.label}\n                      className={cn(\n                        \"absolute grid cursor-pointer place-items-center rounded-full outline-none transition-[box-shadow] before:absolute before:inset-0 before:rounded-full before:bg-current before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-12 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none\",\n                        reduce && \"bg-(--liquid-fab) shadow-sm\",\n                      )}\n                      onClick={() => choose(action)}\n                      ref={(el: HTMLButtonElement | null) => {\n                        buttons.current[i] = el;\n                      }}\n                      style={{\n                        left: size / 2 - actionSize / 2,\n                        top: size / 2 - actionSize / 2,\n                        width: actionSize,\n                        height: actionSize,\n                        x: d.x,\n                        y: d.y,\n                        scale: d.scale,\n                        opacity: d.opacity,\n                      }}\n                      type=\"button\"\n                      whileTap={reduce ? undefined : { scale: 0.92 }}\n                    />\n                  }\n                >\n                  <Icon className=\"size-[18px]\" strokeWidth={2.25} />\n                </TooltipTrigger>\n                <TooltipPopup side={spot.side} sideOffset={8}>\n                  {action.label}\n                </TooltipPopup>\n              </Tooltip>\n            );\n          })}\n        </div>\n\n        <button\n          aria-controls={groupId}\n          aria-expanded={open}\n          aria-label={open ? `Close ${label.toLowerCase()}` : label}\n          className=\"absolute inset-0 grid cursor-pointer touch-manipulation select-none place-items-center rounded-full outline-none [-webkit-tap-highlight-color:transparent] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          onClick={toggle}\n          onPointerCancel={() => !reduce && animate(mainScale, 1, { duration: 0.2, ease: settle })}\n          onPointerDown={() => !reduce && animate(mainScale, 0.93, { duration: 0.12, ease: settle })}\n          onPointerLeave={() => !reduce && animate(mainScale, 1, { duration: 0.2, ease: settle })}\n          onPointerUp={() => !reduce && animate(mainScale, 1, { duration: 0.2, ease: settle })}\n          ref={trigger}\n          type=\"button\"\n        >\n          <motion.span\n            animate={{ rotate: open ? 135 : 0 }}\n            className=\"grid place-items-center\"\n            initial={false}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 28 }}\n          >\n            <PlusIcon className=\"size-6\" strokeWidth={2.25} />\n          </motion.span>\n        </button>\n      </div>\n    </TooltipProvider>\n  );\n}\n"
    }
  ]
}