Domphy

gradientAnimation

A Backgrounds block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call gradientAnimation() with no arguments for a working demo, or edit the code below live.

Implementation notes

Five independently-animated blurred blob layers (two axis-oscillating with alternate direction, one diagonal, two approximate-elliptical orbits via 5 sampled keyframe stops looping one direction) over a base linear-gradient backdrop, each with its own duration (24-40s) and mixed linear/ease-in-out timing so they drift out of phase, composited via a caller-configurable CSS mix-blend-mode (default hard-light) — matches the spec's staggered-timing and blend-mode requirements. The optional pointer-follow blob (on by default, toggle via interactive) uses an imperative rAF-lerped 'ease toward pointer' technique since it can't be a CSS keyframe loop. Deliberate simplification: the reference's additional SVG 'goo' filter (an feColorMatrix alpha-threshold trick that sharpens blur into fused blob edges) was skipped in favor of blur()+mix-blend-mode alone, which already reads as a fluid merging glow without that filter's fiddly, engine-dependent tuning — documented at the top of the file. Blob colors are Domphy theme color roles rather than literal RGB values (info/secondary/primary/error/warning + a pointer role), consistent with the framework's token-only color system; base gradient likewise uses two ThemeColor roles instead of the reference's literal rgb(108,0,162)/rgb(0,17,82) defaults.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Background Gradient Animation" — clean-room reimplementation
// from the public behavior/visual spec only (no upstream source viewed or
// copied). A full-area ambient background made of several large, blurred,
// colored gradient blobs that continuously drift and rotate in slow loops,
// blending into an organic, lava-lamp-like moving gradient over a deep base
// gradient backdrop.
//
// Five blobs, five independent named `@keyframes` (mirrors `warpBackground`'s
// "one animation per decorative layer" shape): two drift back-and-forth on
// one axis (`animation-direction: alternate`), one drifts diagonally, and
// two sweep an approximate elliptical orbit (five sampled keyframe stops,
// looping the same direction — no `alternate`, so it reads as a continuous
// orbit rather than a back-and-forth wobble). Durations/timing functions are
// deliberately mismatched (20–40s, mixed `linear`/`ease-in-out`) so the five
// loops drift out of phase with each other instead of ever looking
// mechanically synchronized. Every blob keyframe encodes its own `-50%,-50%`
// centering baked directly into each stop's `transform: translate(...)`
// value (rather than layering a separate static transform underneath),
// since a single `transform` property can't be animated on top of another
// fixed `transform` — same reasoning as `orbitingCircles.ts`'s own
// combined-transform keyframes.
//
// All blob layers share one wrapper with a heavy `blur()` filter and
// `mix-blend-mode` set per blob, which is what fuses the overlapping edges
// into richer blended hues — this substitutes for the reference's additional
// SVG "goo" alpha-threshold filter (a `feColorMatrix` contrast/threshold
// trick that sharpens blur into blob-like edges): the heavy blur +
// blend-mode combination alone already reads as a fluid, merging glow, and
// skipping the goo filter avoids that filter's very fiddly, engine-dependent
// tuning for a marginal visual gain. See this component's `fidelityNotes`.
//
// The optional pointer-follow blob (on by default) is the one piece that
// can't be a CSS keyframe loop — it must track live mouse coordinates — so
// it's the same imperative, rAF-lerped "ease toward the target" technique
// this package's `lens.ts`/`scrollProgress.ts` use for their own
// high-frequency, purely-visual pointer/scroll following.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

export interface GradientAnimationBlobColors {
  /** Defaults to `"info"` (blue). */
  first?: ThemeColor;
  /** Defaults to `"secondary"` (magenta/purple). */
  second?: ThemeColor;
  /** Defaults to `"primary"` (cyan-leaning accent). */
  third?: ThemeColor;
  /** Defaults to `"error"` (red). */
  fourth?: ThemeColor;
  /** Defaults to `"warning"` (yellow). */
  fifth?: ThemeColor;
  /** Pointer-follow blob color. Defaults to `"highlight"`. */
  pointer?: ThemeColor;
}

export interface GradientAnimationProps {
  /** Base backdrop gradient start color. Defaults to `"secondary"` (deep purple). */
  baseGradientFrom?: ThemeColor;
  /** Base backdrop gradient end color. Defaults to `"info"` (deep blue). */
  baseGradientTo?: ThemeColor;
  /** Per-blob theme color overrides. */
  blobColors?: GradientAnimationBlobColors;
  /** Each blob's size as a percentage of the container. Defaults to `80`. */
  blobSizePercent?: number;
  /** CSS `mix-blend-mode` used to composite the blobs. Defaults to `"hard-light"`. */
  blendMode?: string;
  /** Enables the extra pointer-follow blob layered on top of the passive animation. Defaults to `true`. */
  interactive?: boolean;
  /** Content rendered above the animated background (e.g. hero text/buttons). Defaults to a small demo hero. */
  children?: DomphyElement | DomphyElement[];
  /** Passthrough style merged onto the content slot. */
  contentStyle?: StyleObject;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

interface BlobMotionPreset {
  key: string;
  colorKey: keyof GradientAnimationBlobColors;
  defaultColor: ThemeColor;
  keyframes: Record<string, StyleObject>;
  duration: number;
  timing: string;
  direction?: string;
}

const BLOB_MOTION_PRESETS: BlobMotionPreset[] = [
  {
    key: "horizontal",
    colorKey: "first",
    defaultColor: "info",
    keyframes: {
      "0%": { transform: "translate(-68%, -50%)" },
      "100%": { transform: "translate(-32%, -50%)" },
    },
    duration: 24,
    timing: "ease-in-out",
    direction: "alternate",
  },
  {
    key: "vertical",
    colorKey: "second",
    defaultColor: "secondary",
    keyframes: {
      "0%": { transform: "translate(-50%, -68%)" },
      "100%": { transform: "translate(-50%, -32%)" },
    },
    duration: 28,
    timing: "ease-in-out",
    direction: "alternate",
  },
  {
    key: "diagonal",
    colorKey: "third",
    defaultColor: "primary",
    keyframes: {
      "0%": { transform: "translate(-64%, -64%)" },
      "100%": { transform: "translate(-36%, -36%)" },
    },
    duration: 36,
    timing: "linear",
    direction: "alternate",
  },
  {
    key: "orbit",
    colorKey: "fourth",
    defaultColor: "error",
    keyframes: {
      "0%": { transform: "translate(-50%, -50%)" },
      "25%": { transform: "translate(-22%, -64%)" },
      "50%": { transform: "translate(6%, -50%)" },
      "75%": { transform: "translate(-22%, -36%)" },
      "100%": { transform: "translate(-50%, -50%)" },
    },
    duration: 32,
    timing: "linear",
  },
  {
    key: "orbit-reverse",
    colorKey: "fifth",
    defaultColor: "warning",
    keyframes: {
      "0%": { transform: "translate(-50%, -50%)" },
      "25%": { transform: "translate(-78%, -36%)" },
      "50%": { transform: "translate(-94%, -50%)" },
      "75%": { transform: "translate(-78%, -64%)" },
      "100%": { transform: "translate(-50%, -50%)" },
    },
    duration: 40,
    timing: "linear",
  },
];

let gradientAnimationInstanceCounter = 0;

function defaultHeroContent(): DomphyElement[] {
  return [
    { h1: "An animated, ambient gradient background", $: [heading()] } as DomphyElement,
    {
      p: "Blurred color blobs drift and orbit slowly behind this text, blending into a soft, lava-lamp-like glow.",
      $: [paragraph()],
    } as DomphyElement,
  ];
}

/**
 * A full-area ambient background made of several large, blurred, colored
 * gradient blobs that continuously drift and orbit over a deep base
 * gradient, blending into an organic, lava-lamp-like glow. Call with no
 * arguments for a working demo — five drifting blobs behind a hero heading,
 * with an extra pointer-follow blob layered on top.
 */
function gradientAnimation(props: GradientAnimationProps = {}): DomphyElement<"div"> {
  const instanceId = ++gradientAnimationInstanceCounter;
  const baseGradientFrom = props.baseGradientFrom ?? "secondary";
  const baseGradientTo = props.baseGradientTo ?? "info";
  const blobColors = props.blobColors ?? {};
  const blobSizePercent = props.blobSizePercent ?? 80;
  const blendMode = props.blendMode ?? "hard-light";
  const interactive = props.interactive ?? true;
  const pointerColor = blobColors.pointer ?? "highlight";

  const contentChildren = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : defaultHeroContent();

  const blobElements: DomphyElement[] = BLOB_MOTION_PRESETS.map((preset) => {
    const color = blobColors[preset.colorKey] ?? preset.defaultColor;
    const animationName = `gradient-animation-${preset.key}-${hashString(
      JSON.stringify({ instanceId, preset: preset.key }),
    )}`;
    return {
      div: null,
      _key: `blob-${instanceId}-${preset.key}`,
      ariaHidden: "true",
      // Decorative blurred blob with no text of its own — exempt from the
      // missing-color contract, matching warpBackground.ts's beam spans.
      _doctorDisable: "missing-color",
      style: {
        position: "absolute",
        insetBlockStart: "50%",
        insetInlineStart: "50%",
        width: `${blobSizePercent}%`,
        height: `${blobSizePercent}%`,
        borderRadius: "50%",
        backgroundImage: (listener: Listener) =>
          `radial-gradient(circle at center, ${themeColor(listener, "shift-9", color)} 0%, transparent 60%)`,
        mixBlendMode: blendMode,
        animation: `${animationName} ${preset.duration}s ${preset.timing} infinite${preset.direction ? ` ${preset.direction}` : ""}`,
        [`@keyframes ${animationName}`]: preset.keyframes,
      } as StyleObject,
    } as DomphyElement;
  });

  const pointerBlob: DomphyElement | null = interactive
    ? ({
        div: null,
        dataGradientPointerBlob: "true",
        ariaHidden: "true",
        _doctorDisable: "missing-color",
        style: {
          position: "absolute",
          insetBlockStart: "50%",
          insetInlineStart: "50%",
          width: `${Math.round(blobSizePercent * 0.5)}%`,
          height: `${Math.round(blobSizePercent * 0.5)}%`,
          borderRadius: "50%",
          backgroundImage: (listener: Listener) =>
            `radial-gradient(circle at center, ${themeColor(listener, "shift-9", pointerColor)} 0%, transparent 60%)`,
          mixBlendMode: blendMode,
          opacity: 0.8,
          transform: "translate(-50%, -50%)",
          transition: "opacity 0.4s ease-out",
        } as StyleObject,
      } as DomphyElement)
    : null;

  const blobsWrapper: DomphyElement<"div"> = {
    div: [...blobElements, ...(pointerBlob ? [pointerBlob] : [])],
    ariaHidden: "true",
    style: {
      position: "absolute",
      inset: 0,
      overflow: "hidden",
      filter: `blur(${themeSpacing(20)})`,
    } as StyleObject,
  } as DomphyElement<"div">;

  /** Eases the pointer-follow blob toward live pointer coordinates (rAF-lerped, like `lens.ts`/`scrollProgress.ts`). */
  function pointerFollowMountHandler(node: ElementNode): void {
    if (typeof window === "undefined") return;
    const containerElement = node.domElement as HTMLElement | null;
    const pointerElement = containerElement?.querySelector(
      '[data-gradient-pointer-blob="true"]',
    ) as HTMLElement | null;
    if (!containerElement || !pointerElement) return;

    let currentX = 0.5;
    let currentY = 0.5;
    let targetX = 0.5;
    let targetY = 0.5;
    let animating = false;
    let rafHandle = 0;

    const paint = () => {
      const rect = containerElement.getBoundingClientRect();
      pointerElement.style.transform =
        `translate(${(currentX * rect.width - rect.width / 2).toFixed(1)}px, ` +
        `${(currentY * rect.height - rect.height / 2).toFixed(1)}px) translate(-50%, -50%)`;
    };
    paint();

    const step = () => {
      // Belt-and-suspenders stop condition: some hosts (e.g. a test harness
      // that wipes the DOM directly instead of going through the framework's
      // removal lifecycle) never fire the "Remove" hook below. Bailing here
      // once the node is detached prevents the loop from leaking forever.
      if (!pointerElement.isConnected) return;
      currentX += (targetX - currentX) * 0.12;
      currentY += (targetY - currentY) * 0.12;
      paint();
      if (Math.abs(targetX - currentX) < 0.001 && Math.abs(targetY - currentY) < 0.001) {
        animating = false;
        return;
      }
      rafHandle = window.requestAnimationFrame(step);
    };

    const handlePointerMove = (event: PointerEvent) => {
      const rect = containerElement.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      targetX = (event.clientX - rect.left) / rect.width;
      targetY = (event.clientY - rect.top) / rect.height;
      if (!animating) {
        animating = true;
        rafHandle = window.requestAnimationFrame(step);
      }
    };

    containerElement.addEventListener("pointermove", handlePointerMove);
    node.addHook("Remove", () => {
      containerElement.removeEventListener("pointermove", handlePointerMove);
      if (rafHandle) window.cancelAnimationFrame(rafHandle);
    });
  }

  return {
    div: [
      blobsWrapper,
      {
        div: contentChildren,
        style: {
          position: "relative",
          zIndex: 1,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          textAlign: "center",
          height: "100%",
          ...(props.contentStyle ?? {}),
        } as StyleObject,
      } as DomphyElement<"div">,
    ],
    dataTone: "shift-16",
    // `_onMount` is only included at all when `interactive` is on — Domphy
    // rejects an explicit `_onMount: undefined` hook value, so the key must
    // be omitted entirely (not just set to `undefined`) rather than toggled
    // via a ternary in place.
    ...(interactive ? { _onMount: pointerFollowMountHandler } : {}),
    style: {
      position: "relative",
      overflow: "hidden",
      minHeight: themeSpacing(120),
      backgroundImage: (listener: Listener) =>
        `linear-gradient(to bottom right, ${themeColor(listener, "shift-16", baseGradientFrom)}, ${themeColor(listener, "shift-17", baseGradientTo)})`,
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { gradientAnimation };

← Back to Aceternity UI catalog