{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "droplet-ring",
  "type": "registry:component",
  "title": "Droplet Ring",
  "description": "Your mark on a liquid disc that buds app tiles out like droplets, springs them into a ring, lets them bob for a moment, then pulls them back in and starts again.",
  "categories": [
    "illustration",
    "Connections"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/droplet-ring.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ComponentType, type ReactNode, type SVGProps, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DropletRingHue = \"sky\" | \"rose\" | \"emerald\" | \"violet\" | \"amber\" | \"indigo\" | \"orange\" | \"teal\";\n\nconst hueBg: Record<DropletRingHue, string> = {\n  sky: \"bg-sky-500\",\n  rose: \"bg-rose-500\",\n  emerald: \"bg-emerald-500\",\n  violet: \"bg-violet-500\",\n  amber: \"bg-amber-500\",\n  indigo: \"bg-indigo-500\",\n  orange: \"bg-orange-500\",\n  teal: \"bg-teal-500\",\n};\nconst hueOrder = Object.keys(hueBg) as DropletRingHue[];\n\nexport interface DropletRingApp {\n  name: string;\n  icon: ComponentType<SVGProps<SVGSVGElement>>;\n  hue?: DropletRingHue;\n}\n\nexport interface DropletRingProps {\n  apps: DropletRingApp[];\n  mark?: ReactNode;\n  label?: string;\n  hold?: number;\n  className?: string;\n}\n\nconst launch = { type: \"spring\", stiffness: 170, damping: 15, mass: 0.9 } as const;\nconst gather = { type: \"spring\", stiffness: 210, damping: 26 } as const;\nconst STAGGER = 0.1;\n\nfunction DefaultMark() {\n  return (\n    <svg aria-hidden className=\"size-10\" fill=\"currentColor\" viewBox=\"0 0 40 40\">\n      <path d=\"M20 5a15 15 0 1 0 15 15h-7.5A7.5 7.5 0 1 1 20 12.5V5Z\" />\n      <circle cx=\"29\" cy=\"11\" r=\"5\" />\n    </svg>\n  );\n}\n\nfunction useStageSize() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState({ w: 0, h: 0 });\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight });\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, size] as const;\n}\n\nexport function DropletRing({\n  apps,\n  mark,\n  label = \"Your product connected to its apps\",\n  hold = 5,\n  className,\n}: DropletRingProps) {\n  const filterId = `droplet-ring-goo-${useId().replace(/:/g, \"\")}`;\n  const reduce = useReducedMotion();\n  const [stageRef, size] = useStageSize();\n  const [out, setOut] = useState(false);\n  const [round, setRound] = useState(0);\n\n  useEffect(() => {\n    if (reduce) {\n      setOut(true);\n      return;\n    }\n    let timer: ReturnType<typeof setTimeout>;\n    const cycle = (next: boolean) => {\n      setOut(next);\n      if (next) setRound((r) => r + 1);\n      if (!next) timer = setTimeout(() => cycle(true), 900);\n      else if (hold > 0) timer = setTimeout(() => cycle(false), hold * 1000 + apps.length * STAGGER * 1000);\n    };\n    timer = setTimeout(() => cycle(true), 500);\n    return () => clearTimeout(timer);\n  }, [reduce, hold, apps.length]);\n\n  const radius = Math.min(size.w, size.h) * 0.37;\n  const disc = Math.round(Math.min(size.w, size.h) * 0.3);\n  const drop = Math.round(disc * 0.4);\n  const swell = disc / 2 - drop * 0.3;\n  const ready = size.w > 0;\n  const compact = size.w < 380;\n\n    const motionFor = (i: number) => {\n    if (reduce) return { duration: 0 };\n    const delay = out ? i * STAGGER : (apps.length - i) * 0.05;\n    return out ? { ...launch, delay } : { ...gather, delay };\n  };\n\n  const spots = apps.map((_, i) => {\n    const a = -Math.PI / 2 + (i / apps.length) * Math.PI * 2;\n    return { x: Math.cos(a) * radius, y: Math.sin(a) * radius };\n  });\n\n  return (\n    <div\n      className={cn(\n        \"relative w-full max-w-[480px] overflow-hidden rounded-2xl border bg-card p-4 shadow-xs/5 sm:p-6\",\n        className,\n      )}\n    >\n      <div\n        aria-hidden\n        className=\"dot-grid pointer-events-none absolute inset-0 opacity-50 [mask-image:radial-gradient(closest-side,black,transparent)]\"\n      />\n      <div aria-label={label} className=\"relative mx-auto aspect-[6/5] w-full max-w-[440px]\" ref={stageRef} role=\"img\">\n        {ready && (\n          <>\n            <svg aria-hidden className=\"absolute inset-0 size-full overflow-visible text-foreground\">\n              <circle\n                cx={size.w / 2}\n                cy={size.h / 2}\n                fill=\"none\"\n                r={radius}\n                stroke=\"currentColor\"\n                strokeDasharray=\"3 7\"\n                strokeLinecap=\"round\"\n                strokeOpacity={0.14}\n              />\n              <circle\n                cx={size.w / 2}\n                cy={size.h / 2}\n                fill=\"none\"\n                r={disc / 2 + 14}\n                stroke=\"currentColor\"\n                strokeOpacity={0.08}\n              />\n            </svg>\n\n            <div\n              aria-hidden\n              className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/12 blur-2xl\"\n              style={{ height: disc * 1.6, width: disc * 1.6 }}\n            />\n\n            {apps.map((app, i) => {\n              const Icon = app.icon;\n              const hue = app.hue ?? hueOrder[i % hueOrder.length];\n              return (\n                <motion.div\n                  animate={\n                    out\n                      ? { x: spots[i].x, y: spots[i].y, scale: 1, rotate: 0 }\n                      : { x: 0, y: 0, scale: 0.35, rotate: -20 }\n                  }\n                  className={cn(\"absolute top-1/2 left-1/2\", compact ? \"-mt-5 -ml-5\" : \"-mt-6 -ml-6\")}\n                  initial={false}\n                  key={app.name}\n                  title={app.name}\n                  transition={motionFor(i)}\n                >\n                  <motion.div\n                    animate={reduce || !out ? { y: 0 } : { y: [0, -3, 0, 2, 0] }}\n                    className={cn(\n                      \"relative flex items-center justify-center text-white shadow-md shadow-black/10 inset-shadow-[0_1px_--theme(--color-white/24%)]\",\n                      compact ? \"size-10 rounded-xl\" : \"size-12 rounded-xl\",\n                      hueBg[hue],\n                    )}\n                    transition={{\n                      delay: 1.4 + i * 0.2,\n                      duration: 4 + (i % 3) * 0.7,\n                      ease: \"easeInOut\",\n                      repeat: Number.POSITIVE_INFINITY,\n                    }}\n                  >\n                    <span aria-hidden className=\"absolute inset-0 rounded-[inherit] bg-linear-to-b from-white/18 to-transparent\" />\n                    <Icon aria-hidden className={cn(\"relative drop-shadow-sm\", compact ? \"size-[18px]\" : \"size-[22px]\")} />\n                  </motion.div>\n                </motion.div>\n              );\n            })}\n\n            <svg aria-hidden className=\"absolute size-0\">\n              <filter colorInterpolationFilters=\"sRGB\" height=\"140%\" id={filterId} width=\"140%\" x=\"-20%\" y=\"-20%\">\n                <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation=\"8\" />\n                <feColorMatrix in=\"blur\" result=\"goo\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\" />\n                <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n              </filter>\n            </svg>\n            <div aria-hidden className=\"absolute inset-0\" style={{ filter: `url(#${filterId})` }}>\n              <motion.div\n                animate={round === 0 || reduce ? { scale: 1 } : out ? { scale: [1, 0.96, 1.02, 1] } : { scale: [1, 1.05, 0.98, 1] }}\n                className=\"absolute top-1/2 left-1/2 rounded-full bg-primary\"\n                key={`disc-${round}-${out}`}\n                style={{ height: disc, marginLeft: -disc / 2, marginTop: -disc / 2, width: disc }}\n                transition={{ delay: out ? 0 : 0.4, duration: out ? 0.8 : 0.6, ease: \"easeOut\" }}\n              />\n              {spots.map((spot, i) => {\n                const len = Math.hypot(spot.x, spot.y) || 1;\n                const rim = { x: (spot.x / len) * swell, y: (spot.y / len) * swell };\n                const still = round === 0 || reduce || !out;\n                return (\n                  <motion.div\n                    animate={\n                      still\n                        ? { x: 0, y: 0, scale: 0.6 }\n                        : { x: [0, rim.x, 0], y: [0, rim.y, 0], scale: [0.6, 1, 0.6] }\n                    }\n                    className=\"absolute top-1/2 left-1/2 rounded-full bg-primary\"\n                    initial={false}\n                    key={`${apps[i].name}-${out}`}\n                    style={{ height: drop, marginLeft: -drop / 2, marginTop: -drop / 2, width: drop }}\n                    transition={\n                      still\n                        ? { duration: 0 }\n                        : { delay: i * STAGGER, duration: 0.5, ease: [\"easeOut\", \"easeInOut\"], times: [0, 0.26, 1] }\n                    }\n                  />\n                );\n              })}\n            </div>\n\n            <div className=\"pointer-events-none absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-primary-foreground\">\n              {mark ?? <DefaultMark />}\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}