{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gooey-fab",
  "title": "Gooey FAB",
  "description": "A floating action button that gooey-morphs to extrude its satellite actions like liquid metaballs.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/gooey-fab/gooey-fab.tsx",
      "content": "\"use client\";\n\nimport { motion, useReducedMotion } from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type GooeyFabSize = \"sm\" | \"md\" | \"lg\";\nexport type GooeyFabDirection = \"up\" | \"down\" | \"left\" | \"right\";\n\nexport type GooeyFabAction = {\n  /** Icon node rendered inside the satellite button. */\n  icon: React.ReactNode;\n  /** Accessible label (also a tooltip target). */\n  label: string;\n  onClick?: () => void;\n};\n\nexport type GooeyFabProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\"\n> & {\n  actions: GooeyFabAction[];\n  size?: GooeyFabSize;\n  /** Direction the satellites extrude. Default `\"up\"`. */\n  direction?: GooeyFabDirection;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default `false`. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Icon for the trigger. Defaults to a plus that rotates into a cross. */\n  triggerIcon?: React.ReactNode;\n  /** Accessible label for the trigger. Default `\"Open actions\"`. */\n  triggerLabel?: string;\n};\n\nconst triggerSize: Record<GooeyFabSize, number> = { sm: 44, md: 56, lg: 64 };\nconst satelliteSize: Record<GooeyFabSize, number> = { sm: 36, md: 44, lg: 50 };\n\nconst offsetFor = (\n  direction: GooeyFabDirection,\n  distance: number,\n): { x: number; y: number } => {\n  switch (direction) {\n    case \"down\":\n      return { x: 0, y: distance };\n    case \"left\":\n      return { x: -distance, y: 0 };\n    case \"right\":\n      return { x: distance, y: 0 };\n    default:\n      return { x: 0, y: -distance };\n  }\n};\n\nconst PlusIcon = () => (\n  <svg\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2.5\"\n    strokeLinecap=\"round\"\n    className=\"size-[45%]\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M12 5v14M5 12h14\" />\n  </svg>\n);\n\nconst GooeyFab = React.forwardRef<HTMLDivElement, GooeyFabProps>(\n  (\n    {\n      actions,\n      size = \"md\",\n      direction = \"up\",\n      open: controlled,\n      defaultOpen = false,\n      onOpenChange,\n      triggerIcon,\n      triggerLabel = \"Open actions\",\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduce = useReducedMotion() ?? false;\n    const filterId = React.useId().replace(/:/g, \"\");\n    const isControlled = controlled !== undefined;\n    const [internal, setInternal] = React.useState(defaultOpen);\n    const open = isControlled ? controlled : internal;\n\n    const setOpen = (next: boolean) => {\n      if (!isControlled) setInternal(next);\n      onOpenChange?.(next);\n    };\n\n    const trigger = triggerSize[size];\n    const sat = satelliteSize[size];\n    const gap = 14;\n    const step = sat + gap;\n\n    return (\n      <div\n        ref={forwardedRef}\n        data-open={open ? \"true\" : undefined}\n        className={`relative inline-grid place-items-center ${className ?? \"\"}`}\n        style={{ width: trigger, height: trigger }}\n        {...props}\n      >\n        {/* Blob layer — solid circles fused by the goo filter. Rendered as\n            native SVG (circles inside the filtered <g>) rather than filtered\n            HTML: Safari promotes a transform-animated HTML child to its own\n            compositing layer, which escapes an HTML `filter: url()` and breaks\n            the merge. SVG shapes stay inside the filter, so it fuses on every\n            engine. Origin is the container center; satellites overflow it. */}\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0\"\n          style={{ overflow: \"visible\" }}\n          width={trigger}\n          height={trigger}\n          viewBox={`${-trigger / 2} ${-trigger / 2} ${trigger} ${trigger}`}\n        >\n          <defs>\n            <filter\n              id={filterId}\n              x=\"-400%\"\n              y=\"-400%\"\n              width=\"900%\"\n              height=\"900%\"\n              colorInterpolationFilters=\"sRGB\"\n            >\n              <feGaussianBlur\n                in=\"SourceGraphic\"\n                stdDeviation=\"6\"\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 20 -10\"\n              />\n            </filter>\n          </defs>\n          <g\n            className=\"fill-primary\"\n            filter={reduce ? undefined : `url(#${filterId})`}\n          >\n            <circle cx={0} cy={0} r={trigger / 2} />\n            {actions.map((action, i) => {\n              const target = offsetFor(direction, step * (i + 1));\n              return (\n                <motion.circle\n                  key={action.label}\n                  cx={0}\n                  cy={0}\n                  r={sat / 2}\n                  style={{\n                    transformBox: \"fill-box\",\n                    transformOrigin: \"center\",\n                  }}\n                  initial={false}\n                  animate={\n                    open\n                      ? { x: target.x, y: target.y, scale: 1, opacity: 1 }\n                      : { x: 0, y: 0, scale: 0.2, opacity: 0 }\n                  }\n                  transition={\n                    reduce\n                      ? { duration: 0.15 }\n                      : {\n                          type: \"spring\",\n                          stiffness: 170,\n                          damping: 12,\n                          mass: 0.1,\n                          delay: open ? i * 0.04 : (actions.length - i) * 0.02,\n                        }\n                  }\n                />\n              );\n            })}\n          </g>\n        </svg>\n\n        {/* Control layer: sharp, clickable icon buttons over the blobs. */}\n        <div className=\"absolute inset-0 grid place-items-center\">\n          {actions.map((action, i) => {\n            const target = offsetFor(direction, step * (i + 1));\n            return (\n              <motion.button\n                key={action.label}\n                type=\"button\"\n                aria-label={action.label}\n                tabIndex={open ? 0 : -1}\n                onClick={() => {\n                  action.onClick?.();\n                  setOpen(false);\n                }}\n                className=\"absolute inline-flex items-center justify-center rounded-full text-primary-foreground [outline-offset:2px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n                style={{\n                  width: sat,\n                  height: sat,\n                  pointerEvents: open ? \"auto\" : \"none\",\n                }}\n                initial={false}\n                animate={\n                  open\n                    ? { x: target.x, y: target.y, opacity: 1, scale: 1 }\n                    : { x: 0, y: 0, opacity: 0, scale: 0.4 }\n                }\n                transition={\n                  reduce\n                    ? { duration: 0.15 }\n                    : {\n                        type: \"spring\",\n                        stiffness: 170,\n                        damping: 12,\n                        mass: 0.1,\n                        delay: open ? i * 0.04 + 0.03 : 0,\n                      }\n                }\n              >\n                {action.icon}\n              </motion.button>\n            );\n          })}\n        </div>\n\n        {/* Trigger. */}\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-label={triggerLabel}\n          onClick={() => setOpen(!open)}\n          className=\"relative z-10 inline-flex items-center justify-center rounded-full text-primary-foreground shadow-lg [outline-offset:2px] [transition:scale_120ms_ease] active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          style={{ width: trigger, height: trigger }}\n        >\n          <motion.span\n            className=\"flex size-full items-center justify-center\"\n            animate={{ rotate: open ? 135 : 0 }}\n            transition={\n              reduce\n                ? { duration: 0.15 }\n                : { type: \"spring\", stiffness: 170, damping: 12, mass: 0.1 }\n            }\n          >\n            {triggerIcon ?? <PlusIcon />}\n          </motion.span>\n        </button>\n      </div>\n    );\n  },\n);\nGooeyFab.displayName = \"GooeyFab\";\n\nexport { GooeyFab };\n",
      "type": "registry:ui",
      "target": "components/godui/gooey-fab.tsx"
    }
  ],
  "type": "registry:ui"
}
