Domphy

canvasRevealEffect

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

Implementation notes

Reference is a WebGL fragment shader; substituted with a plain 2D canvas rAF loop (the same technique flickeringGrid.ts already uses in this package) that computes each cell's alpha from a lerped reveal-progress value times a distance-phase-shifted sine wave seeded at the pointer's entry cell, approximating the 'ripple expanding from cursor entry' visual without a shader. The spec's literal RGB-tuple palette prop is replaced with a ThemeColor[] role array (default ['info']) per this codebase's no-raw-color rule; canvas fill colors are resolved once via themeColorToken() (a concrete hex string, not a live var()) so they will not re-resolve on a later runtime theme swap, matching flickeringGrid.ts's own documented tradeoff. Supports both hover-driven and externally controlled active (ValueOrState<boolean>) modes.

Status: partial · Reference: Aceternity UI original

// Aceternity UI "Canvas Reveal Effect" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// dense grid of small colored dots rendered on canvas, dim at rest, that
// animates into a shimmering, ripple-like reveal when the container is
// hovered (or driven programmatically), commonly layered behind a card.
//
// The reference implementation is a WebGL fragment shader; this substitutes
// a plain 2D canvas `requestAnimationFrame` loop (the same technique
// `flickeringGrid()` already uses in this package) — visually equivalent for
// a per-cell shimmer, no WebGL context required. Each grid cell's brightness
// each frame is `revealProgress * paletteOpacity * shimmer`, where `shimmer`
// is a `sin()` wave phase-shifted by the cell's distance from the pointer's
// entry point, so the reveal reads as a ripple expanding outward from where
// the cursor entered rather than every cell lighting up in lockstep.
// `revealProgress` itself is a separate value lerped toward `1` on hover-in
// (or when `active` is driven programmatically) and back toward `0` on
// hover-out, using the same rAF-lerp idiom `scrollProgress()` uses for its
// fill bar. An `IntersectionObserver` pauses the whole loop while the
// canvas is scrolled out of view.

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

export interface CanvasRevealEffectProps {
  /** Theme color roles cycled across the dot palette. Defaults to `["info"]`
   * (a single cyan-reading accent, matching the reference's default). */
  colors?: ThemeColor[];
  /** Per-dot opacity levels cycled for layered depth. Defaults to a
   * ten-step ramp from dim to full. */
  opacities?: number[];
  /** Animation speed multiplier (shimmer + ripple frequency). Defaults to `0.4`. */
  animationSpeed?: number;
  /** Side length of each square dot, in canvas px. Defaults to `3`. */
  dotSize?: number;
  /** Gap between dots, in canvas px. Defaults to `6`. */
  gridGap?: number;
  /** Toggles the radial-gradient vignette overlay that contains the dot field. Defaults to `true`. */
  showVignette?: boolean;
  /** Programmatic reveal control — when provided, hover no longer drives the
   * reveal and this value (or state) does instead. Omit for hover-driven behavior. */
  active?: ValueOrState<boolean>;
  /** Content rendered above the canvas (typically a card). Defaults to a small demo card. */
  children?: DomphyElement | DomphyElement[];
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

const DEFAULT_OPACITIES = [0.3, 0.3, 0.3, 0.5, 0.5, 0.5, 0.8, 0.8, 0.8, 1];

/**
 * A canvas-rendered grid of colored dots that shimmers into a ripple-like
 * reveal on hover (or programmatic `active` control), typically layered
 * behind a card. Call with no arguments for a working demo — hover the panel
 * to see the cyan dot grid reveal itself behind a demo card.
 */
function canvasRevealEffect(props: CanvasRevealEffectProps = {}): DomphyElement<"div"> {
  const colors = props.colors && props.colors.length > 0 ? props.colors : (["info"] as ThemeColor[]);
  const opacities = props.opacities && props.opacities.length > 0 ? props.opacities : DEFAULT_OPACITIES;
  const animationSpeed = props.animationSpeed ?? 0.4;
  const dotSize = Math.max(1, props.dotSize ?? 3);
  const gridGap = Math.max(0, props.gridGap ?? 6);
  const showVignette = props.showVignette ?? true;
  const isControlled = props.active !== undefined;
  const activeState = toState(props.active ?? false, "active");

  const contentChildren: DomphyElement[] = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : [
        {
          div: [
            { h3: "Canvas Reveal Effect", $: [heading()] } as DomphyElement,
            { p: "Hover to reveal the shimmering dot field behind this card.", $: [paragraph({ color: "neutral" })] } as DomphyElement,
          ],
          $: [card({ color: "neutral" })],
        } as DomphyElement,
      ];

  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),
    // mirroring flickeringGrid.ts's own exemption.
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      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;

      const context = canvas.getContext("2d");
      if (!context) return;

      const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      let cssWidth = 0;
      let cssHeight = 0;
      let columns = 0;
      let rows = 0;
      let cellSeeds = new Float32Array(0);
      let cellColorIndex = new Uint8Array(0);
      let cellOpacityIndex = new Uint8Array(0);
      let currentReveal = 0;
      let targetReveal = 0;
      let animationFrameId: number | null = null;
      let resizeObserver: ResizeObserver | null = null;
      let intersectionObserver: IntersectionObserver | null = null;
      let entryColumn = 0;
      let entryRow = 0;

      const fillColors = colors.map((color) => {
        try {
          return themeColorToken(node, "shift-10", color);
        } catch {
          return "#22d3ee";
        }
      });

      function resizeCanvas(): void {
        const rect = containerElement!.getBoundingClientRect();
        cssWidth = rect.width || 320;
        cssHeight = rect.height || 200;
        canvas!.width = Math.max(1, Math.floor(cssWidth * devicePixelRatio));
        canvas!.height = Math.max(1, Math.floor(cssHeight * devicePixelRatio));
        context!.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);

        const cellSpan = dotSize + gridGap;
        columns = Math.max(1, Math.floor(cssWidth / cellSpan));
        rows = Math.max(1, Math.floor(cssHeight / cellSpan));
        const cellCount = Math.max(1, columns * rows);
        cellSeeds = new Float32Array(cellCount);
        cellColorIndex = new Uint8Array(cellCount);
        cellOpacityIndex = new Uint8Array(cellCount);
        for (let index = 0; index < cellCount; index += 1) {
          cellSeeds[index] = Math.random() * Math.PI * 2;
          cellColorIndex[index] = Math.floor(Math.random() * fillColors.length);
          cellOpacityIndex[index] = Math.floor(Math.random() * opacities.length);
        }
        entryColumn = Math.floor(columns / 2);
        entryRow = Math.floor(rows / 2);
      }

      function drawGrid(timeSeconds: number): void {
        context!.clearRect(0, 0, cssWidth, cssHeight);
        if (currentReveal <= 0.001) return;
        const cellSpan = dotSize + gridGap;
        const maxDistance = Math.hypot(columns, rows) || 1;

        for (let row = 0; row < rows; row += 1) {
          for (let column = 0; column < columns; column += 1) {
            const index = row * columns + column;
            const distance = Math.hypot(column - entryColumn, row - entryRow) / maxDistance;
            const wave = Math.sin(timeSeconds * animationSpeed * 4 - distance * 8 + cellSeeds[index]) * 0.5 + 0.5;
            const baseOpacity = opacities[cellOpacityIndex[index]] ?? 0.5;
            const alpha = currentReveal * baseOpacity * (0.35 + wave * 0.65);
            if (alpha <= 0.01) continue;
            context!.globalAlpha = Math.min(1, alpha);
            context!.fillStyle = fillColors[cellColorIndex[index]] ?? fillColors[0];
            context!.fillRect(column * cellSpan, row * cellSpan, dotSize, dotSize);
          }
        }
        context!.globalAlpha = 1;
      }

      function tick(timeMs: number): 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 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;
        const timeSeconds = timeMs / 1000;
        currentReveal += (targetReveal - currentReveal) * 0.08;
        if (Math.abs(targetReveal - currentReveal) < 0.002) currentReveal = targetReveal;
        drawGrid(timeSeconds);
        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;
      }

      function setEntryPoint(clientX: number, clientY: number): void {
        const rect = containerElement!.getBoundingClientRect();
        const cellSpan = dotSize + gridGap;
        entryColumn = Math.max(0, Math.min(columns - 1, Math.floor((clientX - rect.left) / cellSpan)));
        entryRow = Math.max(0, Math.min(rows - 1, Math.floor((clientY - rect.top) / cellSpan)));
      }

      resizeCanvas();

      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();
      }

      let removeHoverListeners: (() => void) | null = null;
      if (!isControlled) {
        const onPointerEnter = (event: PointerEvent) => {
          setEntryPoint(event.clientX, event.clientY);
          targetReveal = 1;
        };
        const onPointerLeave = () => {
          targetReveal = 0;
        };
        containerElement.addEventListener("pointerenter", onPointerEnter);
        containerElement.addEventListener("pointerleave", onPointerLeave);
        removeHoverListeners = () => {
          containerElement!.removeEventListener("pointerenter", onPointerEnter);
          containerElement!.removeEventListener("pointerleave", onPointerLeave);
        };
      }

      targetReveal = activeState.get() ? 1 : 0;
      const releaseActiveListener = activeState.addListener((isActive: boolean) => {
        targetReveal = isActive ? 1 : 0;
      });

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

  const vignetteOverlay: DomphyElement = {
    div: null,
    ariaHidden: "true",
    // Decorative vignette overlay with no text of its own.
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      pointerEvents: "none",
      backgroundImage: (listener) => `radial-gradient(ellipse at center, transparent 40%, ${themeColor(listener, "inherit")} 100%)`,
    } as StyleObject,
  } as DomphyElement;

  return {
    div: [canvasElement, ...(showVignette ? [vignetteOverlay] : []), { div: contentChildren, style: { position: "relative", zIndex: 1, display: "flex", alignItems: "center", justifyContent: "center", height: "100%" } as StyleObject } as DomphyElement],
    dataTone: "shift-15",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(8),
      minHeight: themeSpacing(64),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { canvasRevealEffect };

← Back to Aceternity UI catalog