Domphy

vortex

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

Implementation notes

Canvas 2D requestAnimationFrame particle loop (same shape as this package's particles.ts/flickeringGrid.ts). The noise field driving each particle's swirl angle is a small self-authored classic 2D gradient/Perlin-style noise implementation (permutation table + bilinear interpolation), not simplex-noise - that package is not among this package's installed dependencies (only cobe/canvas-confetti/rough-notation are), so a from-scratch implementation of the standard public-domain Perlin algorithm was used instead; it produces the same smooth, continuous, coherent field the effect needs. Trailing streaks are produced by painting a low-alpha rectangle over the previous frame each tick (rather than clearRect), matching the spec's 'trail, not full clear' description.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Vortex" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// full-canvas swirling particle field — hundreds of small glowing dots
// flowing in smooth noise-driven currents — used as an atmospheric backdrop
// behind centered CTA text/buttons.
//
// Canvas 2D `requestAnimationFrame` loop, the same shape this package's
// `particles.ts`/`flickeringGrid.ts` already use. What's specific to a
// "vortex" (curling, coherent flow rather than independent random drift) is
// how each particle's direction is chosen: every frame, a particle's angle
// comes from sampling a smooth 2D noise field at its own position (plus a
// slowly-advancing time offset baked into the x-coordinate sampled), so
// neighboring particles — which sample nearby noise-field coordinates — drift
// in similar directions and the whole field reads as coherent swirling
// currents, not independent random walks. There is no `simplex-noise`
// dependency installed in this package (only `cobe`/`canvas-confetti`/
// `rough-notation` are) — see `createNoiseField()` below for a small
// self-contained classic gradient-noise (Perlin-style) implementation used
// instead, which produces the same "smooth, continuous, coherent" field
// this effect needs without adding a new dependency.
//
// The canvas is never fully cleared between frames — each frame paints a
// low-alpha rectangle over the previous frame first, which very slightly
// dims everything already drawn rather than erasing it, leaving soft motion
// streaks behind each particle (the classic "trail" trick for this kind of
// flow-field effect).

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

export interface VortexProps {
  /** Number of particles. Defaults to `700`. */
  particleCount?: number;
  /** Base hue, in degrees (0–360). Defaults to `220` (blue). */
  baseHue?: number;
  /** Total hue variation spread across particles, in degrees. Defaults to `100`. */
  hueRange?: number;
  /** Theme color family for the container surface (and the canvas's trailing-fade tint). Defaults to `"neutral"` (near-black). */
  backgroundColor?: ThemeColor;
  /** Minimum per-particle speed, in canvas px/frame. Defaults to `0`. */
  baseSpeed?: number;
  /** Extra randomized speed added on top of `baseSpeed`. Defaults to `1.5`. */
  rangeSpeed?: number;
  /** Minimum particle radius, in canvas px. Defaults to `1`. */
  baseRadius?: number;
  /** Extra randomized radius added on top of `baseRadius`. Defaults to `2`. */
  rangeRadius?: number;
  /** Vertical spawn spread around the container's vertical center, in canvas px. Defaults to `100`. */
  rangeY?: number;
  /** Foreground content centered on top of the particle field. Defaults to a small demo heading/subtext/CTA. */
  children?: DomphyElement | DomphyElement[];
  style?: StyleObject;
}

interface VortexParticle {
  x: number;
  y: number;
  speed: number;
  radius: number;
  hue: number;
  life: number;
}

/**
 * A small, self-contained classic 2D gradient-noise (Perlin-style)
 * generator: a permutation table shuffled by a seeded linear-congruential
 * generator (deterministic per instance, not `Math.random`-order-dependent),
 * bilinear-interpolated with a smootherstep easing curve between lattice
 * gradients. Not `simplex-noise` (not installed in this package) — a
 * from-scratch implementation of the standard public-domain Perlin
 * algorithm, which produces the same "smooth, continuous" field this effect
 * needs to drive coherent-looking particle motion.
 */
function createNoiseField(seed: number): (x: number, y: number) => number {
  const permutation = new Uint8Array(256);
  for (let index = 0; index < 256; index += 1) permutation[index] = index;

  let randomState = (seed >>> 0) || 1;
  const nextRandom = () => {
    randomState = (randomState * 1664525 + 1013904223) >>> 0;
    return randomState / 4294967296;
  };
  for (let index = 255; index > 0; index -= 1) {
    const swapIndex = Math.floor(nextRandom() * (index + 1));
    const temp = permutation[index];
    permutation[index] = permutation[swapIndex];
    permutation[swapIndex] = temp;
  }
  const table = new Uint8Array(512);
  for (let index = 0; index < 512; index += 1) table[index] = permutation[index & 255];

  const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
  const lerp = (a: number, b: number, t: number) => a + t * (b - a);
  const gradient = (hash: number, x: number, y: number) => {
    const h = hash & 7;
    const gradX = h < 4 ? 1 : -1;
    const gradY = h % 4 < 2 ? 1 : -1;
    return gradX * x + gradY * y;
  };

  return function noise2D(x: number, y: number): number {
    const xi = Math.floor(x) & 255;
    const yi = Math.floor(y) & 255;
    const xf = x - Math.floor(x);
    const yf = y - Math.floor(y);
    const u = fade(xf);
    const v = fade(yf);
    const cornerAA = table[table[xi] + yi];
    const cornerAB = table[table[xi] + yi + 1];
    const cornerBA = table[table[xi + 1] + yi];
    const cornerBB = table[table[xi + 1] + yi + 1];
    const lerpX1 = lerp(gradient(cornerAA, xf, yf), gradient(cornerBA, xf - 1, yf), u);
    const lerpX2 = lerp(gradient(cornerAB, xf, yf - 1), gradient(cornerBB, xf - 1, yf - 1), u);
    return lerp(lerpX1, lerpX2, v);
  };
}

function defaultVortexContent(): DomphyElement[] {
  return [
    { h1: "Ambient CTA", $: [heading({ color: "neutral" })] } as DomphyElement,
    {
      p: "A slow-moving field of swirling particles behind your call to action.",
      $: [paragraph({ color: "neutral" })],
    } as DomphyElement,
    {
      button: "Get started",
      type: "button",
      $: [button({ color: "primary" })],
      style: { marginTop: themeSpacing(4) },
    } as DomphyElement,
  ];
}

/**
 * A full-canvas swirling particle field — hundreds of small glowing dots
 * flowing in smooth noise-driven currents — for use as an atmospheric
 * backdrop behind centered CTA content. Purely ambient, non-interactive.
 * Call with no arguments for a working demo — a dark panel with 700
 * drifting blue particles behind a heading, subtext, and CTA button.
 */
function vortex(props: VortexProps = {}): DomphyElement<"div"> {
  const particleCount = Math.max(1, Math.round(props.particleCount ?? 700));
  const baseHue = props.baseHue ?? 220;
  const hueRange = props.hueRange ?? 100;
  const backgroundColor = props.backgroundColor ?? "neutral";
  const baseSpeed = props.baseSpeed ?? 0;
  const rangeSpeed = props.rangeSpeed ?? 1.5;
  const baseRadius = props.baseRadius ?? 1;
  const rangeRadius = props.rangeRadius ?? 2;
  const rangeY = props.rangeY ?? 100;

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

  // `_doctorDisable` is a doctor-only annotation not present in core's strict
  // `PartialElement` type — build through an untyped literal, then assert, so
  // the excess-property check doesn't fire (mirrors particles.ts).
  const canvasElement = {
    canvas: null,
    ariaHidden: "true",
    // Decorative canvas with no text of its own — fill colors are resolved
    // imperatively below (canvas 2D has no themeColor() var() concept).
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      pointerEvents: "none",
    },
    _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 particleList: VortexParticle[] = [];
      let animationFrameId: number | null = null;
      let resizeObserver: ResizeObserver | null = null;
      let elapsedTime = 0;

      const noise2D = createNoiseField(Math.floor(Math.random() * 4294967296));

      // Trailing-fade tint resolved once from the theme, at mount — canvas 2D
      // has no notion of the `var(--…)` reference `themeColor()` returns.
      const trailFillColor = (() => {
        try {
          return themeColorToken(node, "shift-17", backgroundColor);
        } catch {
          return "#000000";
        }
      })();

      function spawnParticle(): VortexParticle {
        return {
          x: Math.random() * canvasWidth,
          y: canvasHeight / 2 + (Math.random() - 0.5) * rangeY * 2,
          speed: baseSpeed + Math.random() * rangeSpeed,
          radius: baseRadius + Math.random() * rangeRadius,
          hue: baseHue + (Math.random() - 0.5) * hueRange,
          life: Math.random() * 200,
        };
      }

      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 generateParticles(): void {
        particleList = Array.from({ length: particleCount }, () => spawnParticle());
      }

      function tick(): void {
        // 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, and unlike
        // `parallaxScroll`/`glowingEffect` this loop has no other convergence
        // condition — it reschedules unconditionally every frame. Bailing here
        // once the canvas is detached prevents it from leaking forever.
        if (!canvas!.isConnected) return;
        elapsedTime += 0.0015;

        // Dim (not clear) the previous frame — leaves soft motion streaks
        // behind each moving particle instead of a hard-edged trail.
        context!.fillStyle = trailFillColor;
        context!.globalAlpha = 0.08;
        context!.fillRect(0, 0, canvasWidth, canvasHeight);
        context!.globalAlpha = 1;

        for (const particle of particleList) {
          const noiseValue = noise2D(
            particle.x * 0.006 + elapsedTime,
            particle.y * 0.006,
          );
          const angle = noiseValue * Math.PI * 4;
          particle.x += Math.cos(angle) * (particle.speed + 0.15);
          particle.y += Math.sin(angle) * (particle.speed + 0.15);
          particle.life -= 1;

          const offCanvas =
            particle.x < -10 || particle.x > canvasWidth + 10 ||
            particle.y < -10 || particle.y > canvasHeight + 10;
          if (offCanvas || particle.life <= 0) {
            Object.assign(particle, spawnParticle());
            continue;
          }

          context!.beginPath();
          context!.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
          context!.fillStyle = `hsla(${particle.hue}, 85%, 65%, 0.8)`;
          context!.fill();
        }

        animationFrameId = window.requestAnimationFrame(tick);
      }

      resizeCanvas();
      generateParticles();
      context.fillStyle = trailFillColor;
      context.fillRect(0, 0, canvasWidth, canvasHeight);
      animationFrameId = window.requestAnimationFrame(tick);

      if (typeof ResizeObserver !== "undefined") {
        resizeObserver = new ResizeObserver(() => {
          resizeCanvas();
          generateParticles();
        });
        resizeObserver.observe(containerElement);
      }

      node.addHook("Remove", () => {
        if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
        resizeObserver?.disconnect();
      });
    },
  } 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%",
        } 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 { vortex };

← Back to Aceternity UI catalog