Domphy

wavyBackground

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

Implementation notes

Canvas 2D rAF loop drawing several overlapping, blurred, semi-transparent wave strokes (one per color) with a slow/fast speed toggle, matching the confirmed-live-screenshot visual (thick glowing multicolor ribbon around vertical mid/lower-mid, heavy blur). Organic variation uses a cheap two-term sum-of-sines per layer rather than true simplex/Perlin noise (this package's only existing noise generator, in vortex.ts, is a full 2D coherent-flow field built for particle drift, overkill for a single flowing ribbon) — a documented, reasonable approximation, not a literal simplex port. The spec's literal hex colors prop is exposed as ThemeColor roles instead (doctor rules forbid raw hex in style; canvas fillStyle strings are resolved from theme tokens imperatively, following this package's vortex.ts/backgroundBeams.ts precedent).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Wavy Background" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// full-bleed animated backdrop of soft, blurred, colorful waves flowing
// behind page content — a glowing aurora/gradient ribbon.
//
// Canvas 2D `requestAnimationFrame` loop, the same shape this package's
// `vortex.ts` already uses for its own particle field: every frame, several
// overlapping wave strokes (one per configured color) are traced across the
// canvas width and stroked with a low `globalAlpha`, with a heavy CSS
// `filter: blur(...)` applied over the whole canvas element so the
// individually-drawn strokes melt into one glowing multicolor ribbon instead
// of showing as separate thin lines — the "many strokes + one shared blur"
// technique already used by this package's `backgroundGradient.ts` for its
// own blob layers, applied here to strokes instead of filled shapes.
//
// Each wave's vertical offset comes from summing two sine terms at different
// frequency/phase (a cheap, dependency-free stand-in for true simplex/Perlin
// noise — `vortex.ts` implements a full gradient-noise field for its own
// coherent particle drift, but a single flowing ribbon only needs a
// less-uniform curve, not a full 2D field) so the curve reads as organic
// rather than a single perfect sine, per the spec's own description.
//
// The upstream spec's `colors` prop is literal hex strings — Domphy's doctor
// rules forbid raw hex/rgb colors on style props, so (matching this
// package's `backgroundBeams.ts`/`vortex.ts`) the palette is exposed as
// `ThemeColor` roles instead, resolved to real hex once via
// `themeColorToken()` for the canvas 2D context (which has no `var()`
// concept), defaulting to `["info", "primary", "secondary", "highlight"]` —
// this theme's closest four built-in families to the documented
// cyan/indigo/purple/fuchsia gradient.

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

export type WavyBackgroundSpeed = "slow" | "fast";

export interface WavyBackgroundProps {
  /** Theme color families cycled across the wave layers. Defaults to `["info", "primary", "secondary", "highlight"]`. */
  colors?: ThemeColor[];
  /** Controls wave frequency/tightness — smaller is tighter. Defaults to `50`. */
  waveWidth?: number;
  /** Theme color family for the base fill behind the waves. Defaults to `"neutral"` (near-black). */
  backgroundColor?: ThemeColor;
  /** Blur radius applied over the canvas, in px. Defaults to `10`. */
  blur?: number;
  /** Overall animation pace. Defaults to `"slow"`. */
  speed?: WavyBackgroundSpeed;
  /** Per-stroke opacity, `0-1`. Defaults to `0.5`. */
  waveOpacity?: number;
  /** Content layered above the waves. Defaults to a small demo heading/subtext. */
  children?: DomphyElement | DomphyElement[];
  /** Passthrough style merged onto the content wrapper. */
  contentStyle?: StyleObject;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

function defaultWavyContent(): DomphyElement[] {
  return [
    { h1: "Wavy Background", $: [heading({ color: "neutral" })] } as DomphyElement,
    {
      p: "A slow, glowing ribbon of color flowing behind your content.",
      $: [paragraph({ color: "neutral" })],
    } as DomphyElement,
  ];
}

/**
 * A full-bleed animated backdrop of soft, blurred, colorful waves flowing
 * behind foreground content — an ambient aurora/gradient-ribbon effect.
 * Purely ambient, non-interactive. Call with no arguments for a working demo
 * — a dark panel with a slow four-color glowing ribbon behind a heading.
 */
function wavyBackground(props: WavyBackgroundProps = {}): DomphyElement<"div"> {
  const colors = props.colors && props.colors.length > 0 ? props.colors : (["info", "primary", "secondary", "highlight"] as ThemeColor[]);
  const waveWidth = Math.max(1, props.waveWidth ?? 50);
  const backgroundColor = props.backgroundColor ?? "neutral";
  const blur = Math.max(0, props.blur ?? 10);
  const speed = props.speed ?? "slow";
  const waveOpacity = Math.min(1, Math.max(0, props.waveOpacity ?? 0.5));

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

  const canvasElement = {
    canvas: null,
    ariaHidden: "true",
    // Decorative canvas with no text of its own — fill/stroke colors are
    // resolved imperatively below (canvas 2D has no themeColor() var() concept).
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      filter: `blur(${blur}px)`,
      pointerEvents: "none",
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      const canvas = node.domElement as HTMLCanvasElement | null;
      const containerElement = canvas?.parentElement ?? null;
      if (!canvas || !containerElement || typeof window === "undefined") return;

      // Headless/test runtimes without a real 2D canvas backend (e.g. jsdom
      // without the optional `canvas` npm package) resolve `getContext` to
      // `null` rather than throwing — bail out before starting the loop.
      const context = canvas.getContext("2d");
      if (!context) return;

      const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      let canvasWidth = 0;
      let canvasHeight = 0;
      let animationFrameId: number | null = null;
      let resizeObserver: ResizeObserver | null = null;
      let intersectionObserver: IntersectionObserver | null = null;
      let elapsedTime = 0;
      const timeStep = speed === "fast" ? 0.018 : 0.006;

      const backgroundToken = (() => {
        try {
          return themeColorToken(node, "shift-17", backgroundColor);
        } catch {
          return "#000000";
        }
      })();
      const strokeTokens = colors.map((color) => {
        try {
          return themeColorToken(node, "shift-9", color);
        } catch {
          return "#38bdf8";
        }
      });

      function resizeCanvas(): void {
        const rect = containerElement!.getBoundingClientRect();
        canvasWidth = rect.width;
        canvasHeight = rect.height;
        canvas!.width = Math.max(1, Math.floor(canvasWidth * devicePixelRatio));
        canvas!.height = Math.max(1, Math.floor(canvasHeight * devicePixelRatio));
        context!.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
      }

      function waveOffset(x: number, layerIndex: number, amplitude: number, frequency: number): number {
        const phase = elapsedTime + layerIndex * 1.7;
        const primary = Math.sin(x * frequency + phase);
        const secondary = Math.sin(x * frequency * 0.47 + phase * 1.3 + layerIndex);
        return primary * amplitude + secondary * amplitude * 0.4;
      }

      function tick(): void {
        elapsedTime += timeStep;

        context!.clearRect(0, 0, canvasWidth, canvasHeight);
        context!.fillStyle = backgroundToken;
        context!.fillRect(0, 0, canvasWidth, canvasHeight);

        const centerY = canvasHeight * 0.58;
        const amplitude = canvasHeight * 0.12;
        const frequency = (Math.PI * 2) / (waveWidth * 20);
        const thickness = Math.max(2, canvasHeight * 0.045);
        const step = Math.max(2, Math.floor(canvasWidth / 220));

        for (let layerIndex = 0; layerIndex < strokeTokens.length; layerIndex += 1) {
          context!.beginPath();
          for (let x = 0; x <= canvasWidth; x += step) {
            const y = centerY + waveOffset(x, layerIndex, amplitude, frequency);
            if (x === 0) context!.moveTo(x, y);
            else context!.lineTo(x, y);
          }
          context!.lineWidth = thickness;
          context!.lineCap = "round";
          context!.lineJoin = "round";
          context!.strokeStyle = strokeTokens[layerIndex];
          context!.globalAlpha = waveOpacity;
          context!.stroke();
        }
        context!.globalAlpha = 1;

        animationFrameId = window.requestAnimationFrame(tick);
      }

      function startLoop(): void {
        if (animationFrameId !== null) return;
        animationFrameId = window.requestAnimationFrame(tick);
      }
      function stopLoop(): void {
        if (animationFrameId === null) return;
        window.cancelAnimationFrame(animationFrameId);
        animationFrameId = null;
      }

      resizeCanvas();
      context.fillStyle = backgroundToken;
      context.fillRect(0, 0, canvasWidth, canvasHeight);

      if (typeof ResizeObserver !== "undefined") {
        resizeObserver = new ResizeObserver(() => resizeCanvas());
        resizeObserver.observe(containerElement);
      }
      if (typeof IntersectionObserver === "function") {
        intersectionObserver = new IntersectionObserver((entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) startLoop();
            else stopLoop();
          }
        });
        intersectionObserver.observe(containerElement);
      } else {
        startLoop();
      }

      node.addHook("Remove", () => {
        stopLoop();
        resizeObserver?.disconnect();
        intersectionObserver?.disconnect();
      });
    },
  } as unknown as DomphyElement<"canvas">;

  return {
    div: [
      canvasElement,
      {
        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,
    ],
    dataTone: "shift-17",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      minHeight: themeSpacing(120),
      padding: themeSpacing(10),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { wavyBackground };

← Back to Aceternity UI catalog