{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-gradient-animation",
  "type": "registry:ui",
  "title": "Background Gradient Animation",
  "description": "The \"lava lamp\" surface: five radial-gradient blobs orbiting behind an SVG goo filter and a 40px blur, over a two-stop gradient backdrop, plus a sixth blob that eases toward the pointer.",
  "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/background-gradient-animation.tsx",
      "target": "components/ui/aceternity/background-gradient-animation.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  type ComponentPropsWithoutRef,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\n\n// @interlace/background-gradient-animation v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/background-gradient-animation\n// What changed since: https://ds.interlace.tools/c/background-gradient-animation#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — BackgroundGradientAnimation\n *\n * The \"lava lamp\" surface: five radial-gradient blobs orbiting behind an SVG\n * goo filter and a 40px blur, over a two-stop gradient backdrop, plus a sixth\n * blob that eases toward the pointer.\n *\n * Every colour is a prop that falls back to an `--interlace-*` token, so the\n * default look is on-brand and no literal ships in source.\n *\n * ## Provenance\n *\n * Our reimplementation of the Aceternity UI component of the same name. What\n * is ours: the whole token fallback chain (upstream shipped colour literals);\n * self-scoping — the original mutated `document.body.style`, this one writes\n * its CSS variables to its own root so several instances can coexist; and a\n * pointer loop that actually schedules frames, replacing upstream's\n * stale-closure nested `setState` that never re-ran.\n *\n * ## Anatomy\n *\n *   div                              (data-slot=\"background-gradient-animation\",\n *                                     isolate, backdrop linear-gradient)\n *     ├─ svg.hidden                  (data-slot=\"gradient-filter\" — feGaussianBlur\n *     │                               → feColorMatrix → feBlend, the goo)\n *     ├─ div aria-hidden             (data-slot=\"gradient-blobs\", filter:url(#<gooId>))\n *     │   ├─ div.animate-first … .animate-fifth\n *     │   └─ div                     (data-slot=\"gradient-pointer\" — only when active)\n *     └─ div.z-10                    (data-slot=\"gradient-content\" — your children)\n *\n * ## Motion — two kinds, gated two ways\n *\n * The five orbits are CSS keyframes (`--animate-first`…`--animate-fifth` in\n * `styles/tokens.css`). They are named in that file's\n * `prefers-reduced-motion: reduce` block (`animation: none !important`) and\n * also caught by the wildcard in `styles/preflight.css`, so they need no JS.\n * The blobs stay on screen, parked.\n *\n * The pointer blob is a `requestAnimationFrame` loop writing\n * `node.style.transform`, which neither CSS rule can reach. It is gated in JS:\n * `pointerActive = interactive && !reducedMotion`, and when that is false the\n * element is not rendered and the effect returns before adding its listener.\n *\n * ## The goo filter id is per-instance\n *\n * `bga-goo-<useId()>`, not a literal. SVG ids are document-global: as\n * `id=\"bga-goo\"` two instances on one page emitted duplicate ids and browsers\n * resolved `url(#bga-goo)` to the first, so both surfaces shared whichever\n * filter mounted first — and unmounting that one took the other's goo with it.\n *\n * The reference therefore moves from an arbitrary Tailwind utility to an\n * inline `style`, because Tailwind scans source as raw text and cannot emit a\n * class whose value is a template literal. That is the same R18 carve-out the\n * colour variables already use, for the same reason: the value is computed.\n */\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/**\n * CSS-blend-mode applied between the orbiting blobs. `hard-light` gives the\n * vivid, saturated aurora look; `normal` flattens them for a calmer surface.\n *\n * Mirrors the CSS `mix-blend-mode` keyword set, narrowed to the values that read\n * well against a gradient backdrop.\n */\nexport type GradientBlendMode =\n  | \"normal\"\n  | \"multiply\"\n  | \"screen\"\n  | \"overlay\"\n  | \"hard-light\"\n  | \"soft-light\"\n  | \"lighten\"\n  | \"darken\";\n\nexport interface BackgroundGradientAnimationProps\n  extends ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * CSS color for the start of the backdrop linear-gradient. Any CSS color is\n   * valid; pass a token reference (e.g. `\"var(--interlace-accent)\"`) to stay on\n   * the design system. When omitted, falls back to the brand accent token.\n   * @default \"var(--interlace-accent)\"\n   */\n  gradientBackgroundStart?: string;\n  /**\n   * CSS color for the end of the backdrop linear-gradient. Pass a token\n   * reference to stay on-brand. When omitted, falls back to the brand\n   * primary-active token.\n   * @default \"var(--interlace-primary-active)\"\n   */\n  gradientBackgroundEnd?: string;\n  /**\n   * CSS color of the first (largest, vertically-drifting) blob. When omitted,\n   * falls back to the brand primary token.\n   * @default \"var(--interlace-primary)\"\n   */\n  firstColor?: string;\n  /**\n   * CSS color of the second (counter-rotating) blob. When omitted, falls back to\n   * the brand primary-hover token.\n   * @default \"var(--interlace-primary-hover)\"\n   */\n  secondColor?: string;\n  /**\n   * CSS color of the third (slow-orbiting) blob. When omitted, falls back to the\n   * brand accent-foreground token.\n   * @default \"var(--interlace-accent-foreground)\"\n   */\n  thirdColor?: string;\n  /**\n   * CSS color of the fourth (horizontally-drifting) blob. When omitted, falls\n   * back to the brand primary-subtle-foreground token.\n   * @default \"var(--interlace-primary-subtle-foreground)\"\n   */\n  fourthColor?: string;\n  /**\n   * CSS color of the fifth (orbiting) blob. When omitted, falls back to the\n   * brand secondary-foreground token.\n   * @default \"var(--interlace-secondary-foreground)\"\n   */\n  fifthColor?: string;\n  /**\n   * CSS color of the pointer-follow blob (only rendered when `interactive`).\n   * When omitted, falls back to the brand primary token.\n   * @default \"var(--interlace-primary)\"\n   */\n  pointerColor?: string;\n  /**\n   * Diameter of each blob as a CSS length (relative to the container). Larger\n   * values produce a softer, more diffuse wash.\n   * @default \"80%\"\n   */\n  size?: string;\n  /**\n   * `mix-blend-mode` applied between the blobs. `hard-light` is the vivid\n   * default; `normal` calms the surface.\n   * @default \"hard-light\"\n   */\n  blendMode?: GradientBlendMode;\n  /**\n   * Render a blob that eases toward the pointer on `pointermove`. Disabled\n   * automatically when the user prefers reduced motion.\n   * @default true\n   */\n  interactive?: boolean;\n  /**\n   * Content rendered above the gradient (z-stacked over the decorative layer).\n   * The gradient layer is `aria-hidden`, so foreground content keeps its own\n   * semantics.\n   */\n  children?: ReactNode;\n  /**\n   * Class name merged onto the foreground content wrapper. Use it to position or\n   * pad your content (the root takes `className` via `...props`).\n   */\n  contentClassName?: string;\n}\n\n/**\n * Shared class list for the five orbiting blobs. Each blob layers its own\n * radial-gradient color (a CSS variable) and orbit animation on top.\n */\nconst BLOB_BASE = cn(\n  \"absolute h-(--bga-size) w-(--bga-size) [mix-blend-mode:var(--bga-blend)]\",\n  \"top-[calc(50%-var(--bga-size)/2)] left-[calc(50%-var(--bga-size)/2)]\",\n  \"[transform-origin:center_center] opacity-100\",\n);\n\nexport const BackgroundGradientAnimation = forwardRef<\n  HTMLDivElement,\n  BackgroundGradientAnimationProps\n>(function BackgroundGradientAnimation(\n  {\n    gradientBackgroundStart,\n    gradientBackgroundEnd,\n    firstColor,\n    secondColor,\n    thirdColor,\n    fourthColor,\n    fifthColor,\n    pointerColor,\n    size = \"80%\",\n    blendMode = \"hard-light\",\n    interactive = true,\n    children,\n    className,\n    contentClassName,\n    style,\n    ...props\n  },\n  ref,\n) {\n  const pointerRef = useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  // Per-instance, because SVG ids are document-global. `useId()`'s output is\n  // stripped to `[A-Za-z0-9_-]` so it is safe inside a `url(#…)` reference\n  // whatever punctuation React decides to spell it with.\n  const gooId = `bga-goo-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  // The pointer-follow blob runs only when interactivity is requested AND the\n  // user has not opted out of motion. Reduced motion always wins.\n  const pointerActive = interactive && !reducedMotion;\n\n  // Smoothly ease the pointer blob toward the cursor with a single rAF loop.\n  // Replaces the original's stale-closure nested-setState loop, which never\n  // actually re-scheduled a frame. All transforms are written to the element\n  // directly (no React re-render per frame), and the loop is fully cleaned up.\n  useEffect(() => {\n    const node = pointerRef.current;\n    if (!node || !pointerActive) return;\n\n    const target = { x: 0, y: 0 };\n    const current = { x: 0, y: 0 };\n    let frame = 0;\n\n    const onPointerMove = (event: PointerEvent) => {\n      const rect = node.getBoundingClientRect();\n      target.x = event.clientX - rect.left;\n      target.y = event.clientY - rect.top;\n    };\n\n    const tick = () => {\n      current.x += (target.x - current.x) / 20;\n      current.y += (target.y - current.y) / 20;\n      node.style.transform = `translate(${Math.round(current.x)}px, ${Math.round(current.y)}px)`;\n      frame = requestAnimationFrame(tick);\n    };\n\n    window.addEventListener(\"pointermove\", onPointerMove);\n    frame = requestAnimationFrame(tick);\n\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      cancelAnimationFrame(frame);\n    };\n  }, [pointerActive]);\n\n  // Dynamic CSS-variable assignments — the lone sanctioned use of inline\n  // `style` (R18). Color props pass through untouched; when a prop is omitted\n  // the fallback token in the radial-gradient class chain takes over, so the\n  // default look is on-brand and no raw color literal ever reaches source.\n  const cssVars = {\n    \"--bga-bg-start\": gradientBackgroundStart,\n    \"--bga-bg-end\": gradientBackgroundEnd,\n    \"--bga-first\": firstColor,\n    \"--bga-second\": secondColor,\n    \"--bga-third\": thirdColor,\n    \"--bga-fourth\": fourthColor,\n    \"--bga-fifth\": fifthColor,\n    \"--bga-pointer\": pointerColor,\n    \"--bga-size\": size,\n    \"--bga-blend\": blendMode,\n    ...style,\n  } as CSSProperties;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"background-gradient-animation\"\n      style={cssVars}\n      className={cn(\n        \"relative isolate h-full w-full overflow-hidden\",\n        // Backdrop linear-gradient: token-defaulted via the var() fallback chain.\n        \"bg-[linear-gradient(40deg,var(--bga-bg-start,var(--interlace-accent)),var(--bga-bg-end,var(--interlace-primary-active)))]\",\n        className,\n      )}\n      {...props}\n    >\n      {/* SVG goo filter — purely decorative, never in the a11y tree. */}\n      <svg aria-hidden=\"true\" className=\"hidden\" data-slot=\"gradient-filter\">\n        <defs>\n          <filter id={gooId}>\n            <feGaussianBlur\n              in=\"SourceGraphic\"\n              stdDeviation=\"10\"\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 18 -8\"\n              result=\"goo\"\n            />\n            <feBlend in=\"SourceGraphic\" in2=\"goo\" />\n          </filter>\n        </defs>\n      </svg>\n\n      {/* Decorative blob layer — non-interactive, hidden from assistive tech. */}\n      <div\n        aria-hidden=\"true\"\n        data-slot=\"gradient-blobs\"\n        className=\"pointer-events-none absolute inset-0\"\n        // Inline rather than an arbitrary `[filter:url(#…)]` utility, and not\n        // by preference: Tailwind scans source as raw TEXT, so a class built\n        // from a template literal is a candidate it never sees and never emits.\n        // The id has to be per-instance, so the declaration has to leave the\n        // class system. Same R18 carve-out as the CSS variables above.\n        style={{ filter: `url(#${gooId}) blur(40px)` }}\n      >\n        <div\n          className={cn(\n            BLOB_BASE,\n            \"[background:radial-gradient(circle_at_center,var(--bga-first,var(--interlace-primary))_0,transparent_50%)_no-repeat]\",\n            \"animate-first\",\n          )}\n        />\n        <div\n          className={cn(\n            BLOB_BASE,\n            \"[background:radial-gradient(circle_at_center,var(--bga-second,var(--interlace-primary-hover))_0,transparent_50%)_no-repeat]\",\n            \"[transform-origin:calc(50%-400px)] animate-second\",\n          )}\n        />\n        <div\n          className={cn(\n            BLOB_BASE,\n            \"[background:radial-gradient(circle_at_center,var(--bga-third,var(--interlace-accent-foreground))_0,transparent_50%)_no-repeat]\",\n            \"[transform-origin:calc(50%+400px)] animate-third\",\n          )}\n        />\n        <div\n          className={cn(\n            BLOB_BASE,\n            \"[background:radial-gradient(circle_at_center,var(--bga-fourth,var(--interlace-primary-subtle-foreground))_0,transparent_50%)_no-repeat]\",\n            \"[transform-origin:calc(50%-200px)] animate-fourth opacity-70\",\n          )}\n        />\n        <div\n          className={cn(\n            BLOB_BASE,\n            \"[background:radial-gradient(circle_at_center,var(--bga-fifth,var(--interlace-secondary-foreground))_0,transparent_50%)_no-repeat]\",\n            \"[transform-origin:calc(50%-800px)_calc(50%+800px)] animate-fifth\",\n          )}\n        />\n\n        {pointerActive && (\n          <div\n            ref={pointerRef}\n            data-slot=\"gradient-pointer\"\n            className={cn(\n              \"absolute -top-1/2 -left-1/2 h-full w-full opacity-70 [mix-blend-mode:var(--bga-blend)]\",\n              \"[background:radial-gradient(circle_at_center,var(--bga-pointer,var(--interlace-primary))_0,transparent_50%)_no-repeat]\",\n            )}\n          />\n        )}\n      </div>\n\n      {/* Foreground content — z-stacked above the decorative layer. */}\n      <div\n        data-slot=\"gradient-content\"\n        className={cn(\"relative z-10\", contentClassName)}\n      >\n        {children}\n      </div>\n    </div>\n  );\n});\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.2.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/background-gradient-animation\n\nInstalled to `components/ui/aceternity/background-gradient-animation.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/aceternity/background-gradient-animation';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/background-gradient-animation\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
