Domphy

svgMaskEffect

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

Implementation notes

Implemented with a CSS mask-image: radial-gradient(...) (plus -webkit- prefix) referencing CSS custom properties, per the spec's own suggested alternative, rather than an SVG <mask> asset with framer-motion motion values (framer-motion is disallowed in this codebase). Pointer x/y are written straight to CSS variables on every pointermove (zero-lag tracking); the reveal radius eases toward its resting/hover target via a small requestAnimationFrame loop that only runs while unconverged (the same 'settled' idiom this package's smoothCursor.ts uses), rather than through WAAPI, since it is a continuously-retargeted single number rather than a fixed from/to keyframe pair.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "SVG Mask Effect" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied).
// Two stacked versions of the same content — a muted base layer and a vivid
// revealed layer beneath it — where the pointer acts like a spotlight
// cutout: only a small circular window into the vivid layer is visible,
// following the cursor with no perceptible lag, and growing on hover.
//
// The reveal window is a CSS `mask-image: radial-gradient(...)` (with the
// `-webkit-` prefix for Safari) on the vivid layer, referencing three CSS
// custom properties (`--reveal-x`/`--reveal-y`/`--reveal-radius`) set on the
// container. Pointer position is written straight to `--reveal-x`/`-y` on
// every `pointermove` — an imperative DOM write, not reactive state, so the
// window tracks the cursor with zero frame lag (the same tradeoff
// `magicCard.ts` makes for its own cursor-following glow). The radius,
// however, eases toward a resting or hover target via a small
// `requestAnimationFrame` loop that only runs while the current value hasn't
// converged yet — the "settled" idiom `smoothCursor.ts` already uses in this
// package — rather than driving it through WAAPI/`motion()`, since this is a
// continuously-retargeted single number, not a fixed from/to keyframe pair.

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

export interface SvgMaskEffectProps {
  /** Content for the muted base layer (always fully visible). Defaults to a small demo blurb. */
  baseContent?: DomphyElement | DomphyElement[];
  /** Content for the vivid layer, only visible through the circular reveal window. Defaults to a colorful demo blurb. */
  revealContent?: DomphyElement | DomphyElement[];
  /** Resting reveal-circle diameter, in px. Defaults to `80`. */
  restingSize?: number;
  /** Reveal-circle diameter while hovered, in px. Defaults to `400`. */
  hoverSize?: number;
  /** Per-frame easing factor (0–1, higher = snappier) for the radius tween. Defaults to `0.18`. */
  easeSpeed?: number;
  style?: StyleObject;
}

function defaultBaseContent(): DomphyElement[] {
  return [
    { h2: "Move your cursor", $: [heading({ color: "neutral" })] } as DomphyElement,
    {
      p: "A hidden, more colorful layer is revealed through a small window that follows your pointer.",
      $: [paragraph({ color: "neutral" })],
    } as DomphyElement,
  ];
}

function defaultRevealContent(): DomphyElement[] {
  return [
    { h2: "There it is", $: [heading({ color: "primary" })] } as DomphyElement,
    {
      p: "A hidden, more colorful layer is revealed through a small window that follows your pointer.",
      $: [paragraph({ color: "info" })],
    } as DomphyElement,
  ];
}

/**
 * Two stacked content layers — a muted base and a vivid alternate hidden
 * beneath it — where the pointer acts as a spotlight cutout revealing a
 * circular patch of the vivid layer, growing on hover. Call with no
 * arguments for a working demo.
 */
function svgMaskEffect(props: SvgMaskEffectProps = {}): DomphyElement<"div"> {
  const restingSize = Math.max(1, props.restingSize ?? 80);
  const hoverSize = Math.max(restingSize, props.hoverSize ?? 400);
  const easeSpeed = Math.min(1, Math.max(0.01, props.easeSpeed ?? 0.18));

  const baseChildren = props.baseContent
    ? Array.isArray(props.baseContent)
      ? props.baseContent
      : [props.baseContent]
    : defaultBaseContent();
  const revealChildren = props.revealContent
    ? Array.isArray(props.revealContent)
      ? props.revealContent
      : [props.revealContent]
    : defaultRevealContent();

  const layerLayout: StyleObject = {
    position: "absolute",
    inset: 0,
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    textAlign: "center",
    padding: themeSpacing(8),
  } as StyleObject;

  const baseLayer: DomphyElement<"div"> = {
    div: baseChildren,
    style: layerLayout,
  };

  const revealMaskImage =
    "radial-gradient(circle var(--reveal-radius, 40px) at var(--reveal-x, 50%) var(--reveal-y, 50%), black 99%, transparent 100%)";

  // `_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 revealLayer = {
    div: [
      {
        div: revealChildren,
        dataTone: "shift-1",
        style: {
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          textAlign: "center",
          padding: themeSpacing(8),
          width: "100%",
          height: "100%",
          backgroundColor: (listener) => themeColor(listener, "inherit", "primary"),
          color: (listener) => themeColor(listener, "shift-11", "primary"),
        } as StyleObject,
      } as DomphyElement,
    ],
    // The doctor's `missing-color` heuristic treats any style value
    // containing the substring `var(` as a themeColor() token and expects a
    // matching `color`; `maskImage`/`WebkitMaskImage` here reference plain
    // CSS custom properties (`--reveal-x`/`-y`/`-radius`, written imperatively
    // in `_onMount` below) that happen to also use `var(...)` syntax but
    // carry no color/theme meaning at all — a false positive.
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      maskImage: revealMaskImage,
      WebkitMaskImage: revealMaskImage,
      maskRepeat: "no-repeat",
      WebkitMaskRepeat: "no-repeat",
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [baseLayer, revealLayer],
    dataTone: "shift-16",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      minHeight: themeSpacing(72),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      cursor: "default",
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      const hostElement = node.domElement as HTMLElement | null;
      if (!hostElement || typeof window === "undefined") return;

      let currentRadius = restingSize / 2;
      let targetRadius = restingSize / 2;
      let animationFrameId: number | null = null;

      hostElement.style.setProperty("--reveal-x", "50%");
      hostElement.style.setProperty("--reveal-y", "50%");
      hostElement.style.setProperty("--reveal-radius", `${currentRadius}px`);

      const tick = () => {
        currentRadius += (targetRadius - currentRadius) * easeSpeed;
        hostElement.style.setProperty("--reveal-radius", `${currentRadius.toFixed(1)}px`);
        animationFrameId =
          Math.abs(targetRadius - currentRadius) < 0.5 ? null : window.requestAnimationFrame(tick);
      };
      const ensureLoopRunning = () => {
        if (animationFrameId === null) animationFrameId = window.requestAnimationFrame(tick);
      };

      const handlePointerMove = (event: PointerEvent) => {
        const rect = hostElement.getBoundingClientRect();
        hostElement.style.setProperty("--reveal-x", `${event.clientX - rect.left}px`);
        hostElement.style.setProperty("--reveal-y", `${event.clientY - rect.top}px`);
      };
      const handlePointerEnter = () => {
        targetRadius = hoverSize / 2;
        ensureLoopRunning();
      };
      const handlePointerLeave = () => {
        targetRadius = restingSize / 2;
        ensureLoopRunning();
      };

      hostElement.addEventListener("pointermove", handlePointerMove);
      hostElement.addEventListener("pointerenter", handlePointerEnter);
      hostElement.addEventListener("pointerleave", handlePointerLeave);

      node.addHook("Remove", () => {
        if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
        hostElement.removeEventListener("pointermove", handlePointerMove);
        hostElement.removeEventListener("pointerenter", handlePointerEnter);
        hostElement.removeEventListener("pointerleave", handlePointerLeave);
      });
    },
  };
}

export { svgMaskEffect };

← Back to Aceternity UI catalog