{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gooey-stack",
  "title": "Gooey Stack",
  "description": "A stack of cards that fuse with a liquid metaball bridge and collapse behind an anchor with a spring.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/gooey-stack/gooey-stack.tsx",
      "content": "\"use client\";\n\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type GooeyStackProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\"\n> & {\n  /**\n   * The inner content of each card, top to bottom — the last is the anchor.\n   * GooeyStack renders the fused `bg-card` surface itself, so children should\n   * be transparent content (padding + text/controls), not their own cards.\n   * Provide 2+.\n   */\n  children?: React.ReactNode;\n  /**\n   * Controlled vertical gap (px) between cards. Negative values overlap and\n   * merge them. When set, this overrides `collapsed` — drive it from a slider\n   * for the full continuous effect.\n   */\n  gap?: number;\n  /** Convenience toggle: springs between `expandedGap` and `collapsedGap`. Default `false`. */\n  collapsed?: boolean;\n  /** Resting gap (px) when expanded. Default `18`. */\n  expandedGap?: number;\n  /** Gap (px) the `collapsed` toggle merges to. Default `-48`. */\n  collapsedGap?: number;\n  /** Blur radius feeding the goo filter — larger fuses cards from further. Default `10`. */\n  gooeyness?: number;\n  /** Corner radius (px) of the fused silhouettes. Match your cards. Default `28`. */\n  radius?: number;\n};\n\n// SPRING.smooth — surfaces / morph (see motion/tokens.ts).\nconst SPRING = {\n  type: \"spring\",\n  stiffness: 320,\n  damping: 32,\n  mass: 0.9,\n} as const;\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\n// How much the cards are *necking* at a given gap: a band-pass that is 0 at both\n// rest ends (fanned out, or fully merged) and peaks while surfaces are close.\nconst nearnessAt = (\n  g: number,\n  expandedGap: number,\n  collapsedGap: number,\n): number =>\n  clamp((expandedGap - g) / Math.max(1, expandedGap - 4), 0, 1) *\n  clamp((g - collapsedGap) / 20, 0, 1);\n\nconst GooeyStack = React.forwardRef<HTMLDivElement, GooeyStackProps>(\n  (\n    {\n      children,\n      gap,\n      collapsed = false,\n      expandedGap = 18,\n      collapsedGap = -48,\n      gooeyness = 10,\n      radius = 28,\n      className,\n      style,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduce = useReducedMotion() ?? false;\n    const filterId = React.useId().replace(/:/g, \"\");\n\n    const items = React.Children.toArray(children);\n    const n = items.length;\n\n    // The effective gap: an explicit `gap` wins, else the toggle picks a preset.\n    const g = gap ?? (collapsed ? collapsedGap : expandedGap);\n\n    // Measure each card so silhouettes and content stay registered.\n    const contentRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n    const [heights, setHeights] = React.useState<number[]>(() =>\n      items.map(() => 0),\n    );\n    // biome-ignore lint/correctness/useExhaustiveDependencies: re-observe when the child count changes\n    React.useLayoutEffect(() => {\n      const measure = () => {\n        setHeights(contentRefs.current.map((el) => el?.offsetHeight ?? 0));\n      };\n      measure();\n      if (typeof ResizeObserver === \"undefined\") return;\n      const ro = new ResizeObserver(measure);\n      for (const el of contentRefs.current) {\n        if (el) ro.observe(el);\n      }\n      return () => ro.disconnect();\n    }, [n]);\n\n    // The stack keeps a constant height (the expanded extent) and the anchor\n    // (last child) stays pinned to the bottom, so it never moves while the\n    // others slide down into it.\n    const heightsBelow = (i: number) => {\n      let d = 0;\n      for (let j = i + 1; j < n; j++) d += heights[j] ?? 0;\n      return d;\n    };\n    const cardsBelow = (i: number) => n - 1 - i;\n\n    const expandedTotal =\n      heights.reduce((s, h) => s + h, 0) + Math.max(0, n - 1) * expandedGap;\n\n    // How far into \"merged\" territory the current gap is (0 while g ≥ 0, ramps\n    // to 1 at collapsedGap). Recede effects only kick in once cards overlap, so\n    // through the small positive-gap zone the cards stay sharp and only their\n    // silhouettes neck together via the goo filter.\n    const merge = clamp(-g / -Math.min(collapsedGap, -1), 0, 1);\n\n    // Cross-fade the crisp native surface (rest → pixel-perfect borders) with the\n    // soft goo-fused surface (mid-transition → the liquid neck). This MUST follow\n    // the *live* animating gap, not the target `g` — at both endpoints necking is\n    // 0, so deriving it from `g` alone would make the goo never appear during the\n    // toggle. Spring a motion value toward `g` and read necking off it live.\n    const gapTarget = useMotionValue(g);\n    React.useEffect(() => {\n      gapTarget.set(g);\n    }, [g, gapTarget]);\n    const gapSpring = useSpring(gapTarget, {\n      stiffness: 320,\n      damping: 32,\n      mass: 0.9,\n    });\n    const nearness = useTransform(gapSpring, (live) =>\n      nearnessAt(live, expandedGap, collapsedGap),\n    );\n    const gooOpacity = useTransform(nearness, (v) => (reduce ? 0 : v));\n    const nativeOpacity = useTransform(nearness, (v) => (reduce ? 1 : 1 - v));\n\n    // Target transform for card `i`. rank = distance from the anchor.\n    const stateOf = (i: number) => {\n      const rank = cardsBelow(i);\n      // Bottom offset for this card at the current gap; anchor stays at 0.\n      const bottomExpanded = heightsBelow(i) + rank * expandedGap;\n      const bottomNow = heightsBelow(i) + rank * g;\n      const y = bottomExpanded - bottomNow; // slide down as the gap shrinks\n      if (rank === 0) {\n        return { y: 0, scale: 1, opacity: 1, silOpacity: 1, blur: 0 };\n      }\n      return {\n        y,\n        scale: 1 - rank * 0.05 * merge,\n        // Reach 0 before full collapse so a receded card leaves no ghost.\n        opacity: Math.max(0, 1 - rank * 1.1 * merge),\n        // The silhouette stays solid through the neck zone, then fades as the\n        // card merges deep so it doesn't poke out behind the anchor.\n        silOpacity: Math.max(0, 1 - merge),\n        blur: Math.min(16, rank * 13 * merge),\n      };\n    };\n\n    const bottomOf = (i: number) =>\n      heightsBelow(i) + cardsBelow(i) * expandedGap;\n    const transition = reduce ? { duration: 0 } : SPRING;\n\n    return (\n      <div\n        ref={forwardedRef}\n        data-slot=\"gooey-stack\"\n        data-collapsed={g < expandedGap ? \"true\" : undefined}\n        className={`relative w-full ${className ?? \"\"}`}\n        style={{ height: expandedTotal || undefined, ...style }}\n        {...props}\n      >\n        {/* Goo filter — fuses the card silhouettes into liquid metaballs. */}\n        <svg aria-hidden=\"true\" className=\"pointer-events-none absolute size-0\">\n          <defs>\n            <filter id={filterId}>\n              <feGaussianBlur\n                in=\"SourceGraphic\"\n                stdDeviation={gooeyness}\n                result=\"blur\"\n              />\n              {/* Hard-edged contour of the blur (razor-steep threshold), so the\n                  fused shape has a crisp edge with almost no anti-aliasing. */}\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 80 -40\"\n                result=\"goo\"\n              />\n              {/* Border: offset the fused shape outward with a *small isotropic*\n                  blur + threshold. A Gaussian is radially symmetric, so the ring\n                  stays round and constant-width at corners and the neck — unlike\n                  box morphology (square corners) or a band off the main blur\n                  (width tracks curvature). */}\n              <feGaussianBlur in=\"goo\" stdDeviation=\"1.2\" result=\"edge\" />\n              <feColorMatrix\n                in=\"edge\"\n                mode=\"matrix\"\n                values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 26 -9\"\n                result=\"grown\"\n              />\n              <feFlood\n                style={{ floodColor: \"var(--border)\" }}\n                result=\"borderColor\"\n              />\n              <feComposite\n                in=\"borderColor\"\n                in2=\"grown\"\n                operator=\"in\"\n                result=\"borderLayer\"\n              />\n              {/* Fill: flat theme card color, masked by `goo` — uniform, opaque,\n                  never darkened by blur anti-aliasing. */}\n              <feFlood\n                style={{ floodColor: \"var(--card)\" }}\n                result=\"cardColor\"\n              />\n              <feComposite\n                in=\"cardColor\"\n                in2=\"goo\"\n                operator=\"in\"\n                result=\"fillLayer\"\n              />\n              <feMerge result=\"surface\">\n                <feMergeNode in=\"borderLayer\" />\n                <feMergeNode in=\"fillLayer\" />\n              </feMerge>\n              {/* Re-introduce sub-pixel anti-aliasing. The steep threshold above\n                  makes a crisp shape but with a hard, aliased (stair-stepped)\n                  edge; a tiny blur smooths the edge back to pixel-perfect without\n                  softening the shape. */}\n              <feGaussianBlur in=\"surface\" stdDeviation=\"0.5\" />\n            </filter>\n          </defs>\n        </svg>\n\n        {/* Merge surface (behind): full-size silhouettes fuse under the goo\n            filter into the liquid neck + border. Only faded in while the cards\n            are necking (`nearness`), so its soft filtered edge is never seen at\n            rest — the crisp native surface below covers it there. */}\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n          style={{\n            opacity: gooOpacity,\n            filter: reduce ? undefined : `url(#${filterId})`,\n          }}\n        >\n          {items.map((_, i) => {\n            const s = stateOf(i);\n            return (\n              <motion.div\n                // biome-ignore lint/suspicious/noArrayIndexKey: index identifies a stable card slot\n                key={i}\n                className=\"absolute inset-x-0 bg-card\"\n                style={{\n                  bottom: bottomOf(i),\n                  height: heights[i] || undefined,\n                  borderRadius: radius,\n                  zIndex: i,\n                }}\n                initial={false}\n                animate={{ y: s.y, scale: s.scale, opacity: s.silOpacity }}\n                transition={transition}\n              />\n            );\n          })}\n        </motion.div>\n\n        {/* Native surface: real DOM cards with CSS borders — pixel-perfect at\n            every zoom. Shown at rest; cross-faded out (`1 - nearness`) into the\n            goo surface above while cards neck, so borders stay crisp at rest and\n            fuse seamlessly during the merge. */}\n        <motion.div\n          className=\"absolute inset-0\"\n          style={{ opacity: nativeOpacity }}\n        >\n          {items.map((_, i) => {\n            const s = stateOf(i);\n            return (\n              <motion.div\n                // biome-ignore lint/suspicious/noArrayIndexKey: index identifies a stable card slot\n                key={i}\n                className=\"absolute inset-x-0 border border-border bg-card\"\n                style={{\n                  bottom: bottomOf(i),\n                  height: heights[i] || undefined,\n                  borderRadius: radius,\n                  zIndex: i,\n                }}\n                initial={false}\n                animate={{ y: s.y, scale: s.scale, opacity: s.opacity }}\n                transition={transition}\n              />\n            );\n          })}\n        </motion.div>\n\n        {/* Content: children on top of whichever surface is showing. Stays crisp\n            and readable — only recedes/frosts, never cross-faded by the neck. */}\n        <div className=\"absolute inset-0\">\n          {items.map((child, i) => {\n            const s = stateOf(i);\n            return (\n              <motion.div\n                // biome-ignore lint/suspicious/noArrayIndexKey: index identifies a stable card slot\n                key={i}\n                ref={(el) => {\n                  contentRefs.current[i] = el;\n                }}\n                className=\"absolute inset-x-0\"\n                style={{ bottom: bottomOf(i), zIndex: i }}\n                initial={false}\n                animate={{\n                  y: s.y,\n                  scale: s.scale,\n                  opacity: s.opacity,\n                  filter: reduce ? \"blur(0px)\" : `blur(${s.blur}px)`,\n                }}\n                transition={transition}\n              >\n                {child}\n              </motion.div>\n            );\n          })}\n        </div>\n      </div>\n    );\n  },\n);\nGooeyStack.displayName = \"GooeyStack\";\n\nexport { GooeyStack };\n",
      "type": "registry:ui",
      "target": "components/godui/gooey-stack.tsx"
    }
  ],
  "type": "registry:ui"
}
