{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cloud-particles",
  "type": "registry:ui",
  "title": "Cloud Particles",
  "description": "Volumetric drifting clouds as a decorative backdrop. Each cloud is a radial-gradient ellipse pushed through a five-pass SVG turbulence filter — body, cool underside, soft shadow, deep shadow — and translated across `130vw`.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/aceternity/cloud-particles.tsx",
      "target": "components/ui/aceternity/cloud-particles.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { ComponentPropsWithoutRef, useEffect, useId, useState } from \"react\";\n\n// @interlace/cloud-particles v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/cloud-particles\n// What changed since: https://ds.interlace.tools/c/cloud-particles#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — CloudParticles\n *\n * Volumetric drifting clouds as a decorative backdrop. Each cloud is a\n * radial-gradient ellipse pushed through a five-pass SVG turbulence filter —\n * body, cool underside, soft shadow, deep shadow — and translated across\n * `130vw`.\n *\n * It is an `absolute inset-0` overlay: `aria-hidden`, `pointer-events-none`,\n * and reserving no flow space, so the consumer owns the positioned ancestor.\n *\n * ## Provenance\n *\n * No MUI or shadcn analogue exists for an atmospheric layer, so the shape\n * follows the local `aceternity/` convention (`StarsBackground`, `Meteors`).\n * Against the effect this was adapted from, what is ours: every `floodColor`\n * is a prop defaulting to a CSS custom property instead of a baked `rgb()`\n * literal; the filter id comes from `useId()` rather than one global id that\n * two mounted instances would collide on; and the reduced-motion frame below.\n *\n * ## Anatomy\n *\n *   CloudParticles                   (div — data-slot=\"cloud-particles\",\n *                                     aria-hidden, pointer-events-none)\n *     ├─ style                       (per-instance `{filterId}-drift` keyframe)\n *     ├─ svg                         (data-slot=\"cloud-particles-filter\" —\n *     │                               2× feTurbulence, then 4 displaced layers\n *     │                               composited through feMerge)\n *     └─ div ×count                  (data-slot=\"cloud-particles-cloud\")\n *         └─ div                     (data-slot=\"cloud-particles-shape\")\n *\n * Layout is a deterministic golden-ratio walk (`buildClouds`), not\n * `Math.random()`, so server and client agree and the same props always\n * produce the same field. `count` is clamped to `mobileCount` below\n * `mobileBreakpoint`. Both the keyframe and the clouds render only after\n * mount, so SSR emits the filter and nothing else.\n *\n * ## Motion\n *\n * A CSS keyframe, gated three ways. The drift class is written\n * `motion-safe:animate-[var(--cloud-animation)]` and is only added when\n * `!reducedMotion`; the `--cloud-animation` variable itself is set to `none`\n * under `reduce`; and the wildcard in `styles/preflight.css` would clamp it\n * regardless. Note that the keyframe is injected by this component under a\n * per-instance name, so it is NOT in the `animation: none` list in\n * `styles/tokens.css` — the `motion-safe` variant is what does the work.\n *\n * Under `reduce` the clouds stay on screen, scaled and still (`transform:\n * scale(...)` replaces the animation), because the atmosphere is the point and\n * the drift is decoration on top of it.\n *\n * ## Why `bodyColor` is NOT `currentColor`\n *\n * It was `var(--cloud-body-color, currentColor)`, and since the DS declares no\n * `--cloud-body-color`, `currentColor` WAS the shipped default: the cloud body\n * painted in whatever text colour the overlay happened to inherit. Over a dark\n * hero that is near-white and looks deliberate, which is why it survived\n * review; over a light hero it is `--foreground` (`#0d0b09`) and the field\n * renders as a near-black smear. Only a browser shows it — jsdom has no\n * cascade and the token is syntactically valid either way.\n *\n * `currentColor` is a good default for a STROKE or a glyph: a line or an icon\n * is a mark on top of text and should read as part of it. A volumetric fill is\n * not a mark, it is a material — it stands in for the light scattering off\n * water vapour — and no material's colour is a function of the paragraph it\n * happens to sit near. Inheriting there means the fill inverts with the theme\n * while the thing it depicts does not.\n *\n * The default is now `var(--scrim-foreground)`: the DS's \"light by intent, not\n * by mode\" token, `#ffffff` in BOTH schemes (its partner `--scrim` is the\n * matching always-dark one). A cloud is lit from above in daylight and lit\n * from below at night; in neither case does it invert to near-black. The\n * underside and shadow layers keep reading `--muted-foreground`, which DOES\n * invert — that is correct, because those are shading, and shading is relative\n * to the surface behind it.\n *\n * Note the failure mode if a fork ships neither property: `var()` with no\n * usable substitution makes the whole `background` declaration invalid at\n * computed-value time, so the cloud paints nothing. Invisible beats a black\n * smear.\n */\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\ninterface CloudMeta {\n  id: number;\n  /** Horizontal start, in % of the container width. */\n  x: number;\n  /** Vertical position, in % of the container height. */\n  y: number;\n  /** Per-cloud scale multiplier (1 = native 320×140px). */\n  scale: number;\n  /** Per-cloud opacity. */\n  opacity: number;\n  /** Drift duration, in seconds. */\n  speed: number;\n  /** Stagger delay, in seconds. */\n  delay: number;\n}\n\ninterface CloudParticlesProps extends ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * Number of cloud particles to render. On viewports narrower than\n   * `mobileBreakpoint` this is clamped to `mobileCount` to protect the GPU\n   * budget on phones.\n   * @default 3\n   */\n  count?: number;\n  /**\n   * Maximum cloud count on viewports narrower than `mobileBreakpoint`.\n   * @default 2\n   */\n  mobileCount?: number;\n  /**\n   * Viewport width (px) below which `mobileCount` applies.\n   * @default 768\n   */\n  mobileBreakpoint?: number;\n  /**\n   * Slowest drift duration, in seconds (each cloud picks a value in\n   * `[minSpeed, maxSpeed]`). Larger = slower.\n   * @default 150\n   */\n  minSpeed?: number;\n  /**\n   * Fastest drift duration, in seconds.\n   * @default 250\n   */\n  maxSpeed?: number;\n  /**\n   * Smallest cloud scale (1 = native 320×140px).\n   * @default 0.5\n   */\n  minScale?: number;\n  /**\n   * Largest cloud scale.\n   * @default 0.9\n   */\n  maxScale?: number;\n  /**\n   * Main cloud-body color. Any CSS color is valid; defaults resolve through a\n   * CSS custom property so the design system owns the palette.\n   *\n   * Defaults to `--scrim-foreground` — white in both schemes — because a\n   * volumetric fill is a material, not a mark, and must not invert with the\n   * surrounding text colour. Pass `currentColor` explicitly if you genuinely\n   * want the clouds to track the inherited foreground; see the file header for\n   * why that is the wrong default.\n   * @default \"var(--cloud-body-color, var(--scrim-foreground))\"\n   */\n  bodyColor?: string;\n  /**\n   * Cool underside tint that reads as light-from-above. Falls back to the\n   * theme's muted-foreground token.\n   * @default \"var(--cloud-underside-color, var(--muted-foreground, currentColor))\"\n   */\n  undersideColor?: string;\n  /**\n   * Soft drop-shadow color beneath each cloud.\n   * @default \"var(--cloud-shadow-color, var(--muted-foreground, currentColor))\"\n   */\n  shadowColor?: string;\n  /**\n   * Opacity of the underside tint layer (0–1).\n   * @default 0.08\n   */\n  undersideOpacity?: number;\n  /**\n   * Opacity of the soft-shadow layer (0–1).\n   * @default 0.12\n   */\n  shadowOpacity?: number;\n  /**\n   * Opacity of the deep-shadow layer (0–1).\n   * @default 0.08\n   */\n  deepShadowOpacity?: number;\n  /**\n   * Stable selector for E2E tests. Required at the type level so consumers\n   * never ship an untested overlay; there is intentionally no runtime default.\n   */\n  \"data-testid\": string;\n}\n\nconst PHI = 1.618033988749;\nconst NATIVE_WIDTH = 320;\nconst NATIVE_HEIGHT = 140;\n\n/**\n * Deterministic golden-ratio layout — same input always yields the same cloud\n * field, so server and client render identically (no hydration mismatch).\n */\nfunction buildClouds(\n  count: number,\n  minSpeed: number,\n  maxSpeed: number,\n  minScale: number,\n  maxScale: number,\n): CloudMeta[] {\n  return Array.from({ length: Math.max(0, count) }, (_, idx) => {\n    const seed = (idx * PHI) % 1;\n    const seed2 = ((idx + 1) * PHI * 0.7) % 1;\n    const seed3 = ((idx + 2) * PHI * 0.5) % 1;\n    return {\n      id: idx,\n      x: -15 + seed * 30,\n      y: 6 + seed2 * 16,\n      scale: minScale + seed3 * (maxScale - minScale),\n      opacity: 0.85 + seed * 0.15,\n      speed: minSpeed + seed2 * (maxSpeed - minSpeed),\n      delay: idx * 25,\n    };\n  });\n}\n\n/**\n * CloudParticles — see file header for the visual model and deviations.\n *\n * Layout (CLS=0): a `pointer-events-none absolute inset-0` decorative overlay.\n * It reserves no flow space and is `aria-hidden`, so it never shifts content or\n * reaches assistive tech. The consumer owns the positioned ancestor.\n */\nexport function CloudParticles({\n  count = 3,\n  mobileCount = 2,\n  mobileBreakpoint = 768,\n  minSpeed = 150,\n  maxSpeed = 250,\n  minScale = 0.5,\n  maxScale = 0.9,\n  bodyColor = \"var(--cloud-body-color, var(--scrim-foreground))\",\n  undersideColor = \"var(--cloud-underside-color, var(--muted-foreground, currentColor))\",\n  shadowColor = \"var(--cloud-shadow-color, var(--muted-foreground, currentColor))\",\n  undersideOpacity = 0.08,\n  shadowOpacity = 0.12,\n  deepShadowOpacity = 0.08,\n  className,\n  \"data-testid\": testId,\n  ...props\n}: CloudParticlesProps) {\n  const reducedMotion = useReducedMotion();\n  // Per-instance filter id — stable across SSR/CSR, collision-free across mounts.\n  const rawId = useId();\n  const filterId = `cloud-filter-${rawId.replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  const [effectiveCount, setEffectiveCount] = useState(count);\n  // Mount flag: the keyframe stylesheet and clouds are injected client-side so\n  // the deterministic field never fights React hydration.\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n    const apply = () => {\n      const mobile = window.innerWidth < mobileBreakpoint;\n      setEffectiveCount(mobile ? Math.min(count, mobileCount) : count);\n    };\n    apply();\n    window.addEventListener(\"resize\", apply);\n    return () => window.removeEventListener(\"resize\", apply);\n  }, [count, mobileCount, mobileBreakpoint]);\n\n  const clouds = buildClouds(\n    effectiveCount,\n    minSpeed,\n    maxSpeed,\n    minScale,\n    maxScale,\n  );\n\n  // Drift is the only animated property. Scoped to this instance's filter id so\n  // multiple overlays on one page never collide on the keyframe name either.\n  const keyframes = `\n    @keyframes ${filterId}-drift {\n      0% { transform: translateX(0) scale(var(--cloud-scale, 1)); }\n      100% { transform: translateX(130vw) scale(var(--cloud-scale, 1)); }\n    }\n  `;\n\n  return (\n    <div\n      data-slot=\"cloud-particles\"\n      data-testid={testId}\n      aria-hidden\n      className={cn(\n        \"pointer-events-none absolute inset-0 overflow-hidden\",\n        className,\n      )}\n      {...props}\n    >\n      {mounted && (\n        <style suppressHydrationWarning>{keyframes}</style>\n      )}\n\n      {/* Volumetric cloud filter — fractal noise displaced into fluffy edges,\n          then merged back-to-front: deep shadow, soft shadow, cool underside,\n          white body. Colors flow in from props (no baked literals). */}\n      <svg\n        data-slot=\"cloud-particles-filter\"\n        className=\"absolute h-0 w-0\"\n        aria-hidden\n      >\n        <defs>\n          <filter\n            id={filterId}\n            x=\"-100%\"\n            y=\"-100%\"\n            width=\"300%\"\n            height=\"300%\"\n          >\n            <feTurbulence\n              type=\"fractalNoise\"\n              baseFrequency=\"0.012\"\n              numOctaves={5}\n              seed={15}\n              result=\"noiseDetail\"\n            />\n            <feTurbulence\n              type=\"fractalNoise\"\n              baseFrequency=\"0.0096\"\n              numOctaves={2}\n              seed={42}\n              result=\"noiseBroad\"\n            />\n\n            {/* Layer 1 — main body. */}\n            <feGaussianBlur\n              in=\"SourceGraphic\"\n              stdDeviation={18}\n              result=\"bodyBlur\"\n            />\n            <feDisplacementMap\n              in=\"bodyBlur\"\n              in2=\"noiseDetail\"\n              scale={90}\n              xChannelSelector=\"R\"\n              yChannelSelector=\"G\"\n              result=\"layerBody\"\n            />\n\n            {/* Layer 2 — cool underside tint. */}\n            <feFlood\n              floodColor={undersideColor}\n              floodOpacity={undersideOpacity}\n              result=\"undersideFlood\"\n            />\n            <feOffset in=\"SourceGraphic\" dx={-8} dy={35} result=\"undersideOffset\" />\n            <feGaussianBlur in=\"undersideOffset\" stdDeviation={18} result=\"undersideBlur\" />\n            <feDisplacementMap\n              in=\"undersideBlur\"\n              in2=\"noiseDetail\"\n              scale={85}\n              xChannelSelector=\"R\"\n              yChannelSelector=\"G\"\n              result=\"undersideShape\"\n            />\n            <feComposite\n              in=\"undersideFlood\"\n              in2=\"undersideShape\"\n              operator=\"in\"\n              result=\"layerUnderside\"\n            />\n\n            {/* Layer 3 — soft shadow. */}\n            <feFlood\n              floodColor={shadowColor}\n              floodOpacity={shadowOpacity}\n              result=\"softFlood\"\n            />\n            <feOffset in=\"SourceGraphic\" dx={15} dy={50} result=\"softOffset\" />\n            <feGaussianBlur in=\"softOffset\" stdDeviation={25} result=\"softBlur\" />\n            <feDisplacementMap\n              in=\"softBlur\"\n              in2=\"noiseBroad\"\n              scale={70}\n              xChannelSelector=\"R\"\n              yChannelSelector=\"G\"\n              result=\"softShape\"\n            />\n            <feComposite\n              in=\"softFlood\"\n              in2=\"softShape\"\n              operator=\"in\"\n              result=\"layerSoftShadow\"\n            />\n\n            {/* Layer 4 — deep shadow for depth. */}\n            <feFlood\n              floodColor={shadowColor}\n              floodOpacity={deepShadowOpacity}\n              result=\"deepFlood\"\n            />\n            <feOffset in=\"SourceGraphic\" dx={18} dy={60} result=\"deepOffset\" />\n            <feGaussianBlur in=\"deepOffset\" stdDeviation={28} result=\"deepBlur\" />\n            <feDisplacementMap\n              in=\"deepBlur\"\n              in2=\"noiseBroad\"\n              scale={80}\n              xChannelSelector=\"R\"\n              yChannelSelector=\"G\"\n              result=\"deepShape\"\n            />\n            <feComposite\n              in=\"deepFlood\"\n              in2=\"deepShape\"\n              operator=\"in\"\n              result=\"layerDeepShadow\"\n            />\n\n            <feMerge>\n              <feMergeNode in=\"layerDeepShadow\" />\n              <feMergeNode in=\"layerSoftShadow\" />\n              <feMergeNode in=\"layerUnderside\" />\n              <feMergeNode in=\"layerBody\" />\n            </feMerge>\n          </filter>\n        </defs>\n      </svg>\n\n      {mounted &&\n        clouds.map((cloud) => (\n          <div\n            key={`cloud-${cloud.id}`}\n            data-slot=\"cloud-particles-cloud\"\n            className={cn(\n              \"absolute will-change-transform\",\n              !reducedMotion &&\n                \"motion-safe:animate-[var(--cloud-animation)]\",\n            )}\n            style={{\n              left: `${cloud.x}%`,\n              top: `${cloud.y}%`,\n              width: NATIVE_WIDTH,\n              height: NATIVE_HEIGHT,\n              opacity: cloud.opacity,\n              // CSS custom properties drive the per-cloud keyframe; all of these\n              // are computed, not static, so inline style is the correct home.\n              [\"--cloud-scale\"]: String(cloud.scale),\n              [\"--cloud-animation\"]: reducedMotion\n                ? \"none\"\n                : `${filterId}-drift ${cloud.speed}s linear ${cloud.delay}s infinite`,\n              transform: reducedMotion\n                ? `scale(${cloud.scale})`\n                : undefined,\n            } as React.CSSProperties}\n          >\n            <div\n              data-slot=\"cloud-particles-shape\"\n              className=\"h-full w-full rounded-full\"\n              style={{\n                background: `radial-gradient(ellipse 55% 45% at 50% 45%, ${bodyColor} 0%, ${bodyColor} 30%, transparent 100%)`,\n                filter: `url(#${filterId})`,\n              }}\n            />\n          </div>\n        ))}\n    </div>\n  );\n}\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/cloud-particles\n\nInstalled to `components/ui/aceternity/cloud-particles.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/aceternity/cloud-particles';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/cloud-particles\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
