Domphy

backgroundRippleEffect

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

Implementation notes

Declarative grid of bordered <div> cells (rows*columns, default 8x27 @56px) matching the spec's own DOM sketch, not canvas. On click, per-cell grid-distance from the clicked cell is computed once in JS and written as a proportional animation-delay into each cell's style.animation (all cells sharing one @keyframes declared on the container), so the ripple's propagation is driven by the compositor, not a JS frame loop. Setting animation: 'none' + forcing a reflow before reapplying lets the same cell replay on a second click. Cell fill uses color (themed) + background-color: currentColor rather than a themed backgroundColor directly, since Domphy's doctor flags any non-'inherit' themed backgroundColor (tone-background-inherit) — this substitution keeps the fill independently theme-colorable while staying compliant.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Background Ripple Effect" — clean-room reimplementation
// from the public behavior/visual spec only (no upstream source viewed or
// copied). A dense grid of bordered square cells that, on click, sends a
// visible ripple of highlighted opacity radiating outward from the clicked
// cell across the rest of the grid.
//
// The grid is a plain declarative array of bordered `<div>` cells (matching
// the spec's own DOM sketch), the same shape this package's
// `interactiveGridPattern` uses for its own grid — small enough at the
// default 8×27 count that per-cell DOM nodes are cheap, unlike the
// canvas-per-pixel approach this package reaches for on much larger
// continuous-animation grids (`flickeringGrid`, `dottedGlowBackground`).
//
// Distance-based CSS `animation-delay` technique: on click, the
// row/column Euclidean distance from the clicked cell to every other cell is
// computed once in JS, then every cell's `style.animation` is written
// imperatively with a delay proportional to that distance — so farther
// cells start their pulse later — all sharing ONE `@keyframes` (declared
// once on the grid container, referenced by name) that ramps opacity from a
// low resting value up to a peak around the animation's midpoint and back
// down, with an ease-out curve. Because propagation is expressed purely as
// per-cell `animation-delay` plus a single shared keyframe, the browser's
// compositor drives the whole ripple — no continuous JS frame loop. Setting
// `style.animation = "none"` and forcing a reflow (`void cell.offsetWidth`)
// before reapplying it is what allows the same cell to replay the ripple on
// a second click before its first pulse has finished.

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 BackgroundRippleCell {
  row: number;
  column: number;
}

export interface BackgroundRippleEffectProps {
  /** Number of grid rows. Defaults to `8`. */
  rows?: number;
  /** Number of grid columns. Defaults to `27`. */
  columns?: number;
  /** Side length of each square cell, in px. Defaults to `56`. */
  cellSize?: number;
  /** Theme color family for cell borders. Defaults to `"neutral"`. */
  borderColor?: ThemeColor;
  /** Theme color family for the cell fill. Defaults to `"neutral"`. */
  fillColor?: ThemeColor;
  /** One ripple pulse's duration, in ms. Defaults to `200`. */
  rippleDuration?: number;
  /** Extra delay added per unit of grid-distance from the clicked cell, in ms. Defaults to `20`. */
  staggerMs?: number;
  /** Enables/disables click interactivity. Defaults to `true`. */
  interactive?: boolean;
  /** Fires with the clicked cell's row/column, in addition to the visual ripple. */
  onCellClick?: (cell: BackgroundRippleCell) => void;
  /** Foreground content layered above the grid. Defaults to a small demo panel. */
  children?: DomphyElement | DomphyElement[];
  /** Passthrough style merged onto the outer grid container. */
  style?: StyleObject;
}

const RESTING_OPACITY = 0.4;
const PEAK_OPACITY = 0.8;

let backgroundRippleEffectInstanceCounter = 0;

/**
 * A dense grid of bordered square cells that, on click, sends a visible
 * ripple of highlighted opacity radiating outward from the clicked cell.
 * Call with no arguments for a working demo — click any cell in the 8×27
 * grid to see the ripple propagate.
 */
function backgroundRippleEffect(props: BackgroundRippleEffectProps = {}): DomphyElement<"div"> {
  const instanceId = ++backgroundRippleEffectInstanceCounter;
  const rows = Math.max(1, Math.round(props.rows ?? 8));
  const columns = Math.max(1, Math.round(props.columns ?? 27));
  const cellSize = Math.max(4, props.cellSize ?? 56);
  const borderColor = props.borderColor ?? "neutral";
  const fillColor = props.fillColor ?? "neutral";
  const rippleDuration = Math.max(1, props.rippleDuration ?? 200);
  const staggerMs = Math.max(0, props.staggerMs ?? 20);
  const interactive = props.interactive ?? true;

  const rippleAnimationName = `background-ripple-pulse-${hashString(
    JSON.stringify({ instanceId, RESTING_OPACITY, PEAK_OPACITY }),
  )}`;
  const rippleKeyframes = {
    "0%": { opacity: RESTING_OPACITY },
    "50%": { opacity: PEAK_OPACITY },
    "100%": { opacity: RESTING_OPACITY },
  };

  // Populated by each cell's own `_onMount`; the grid's own `_onMount` reads
  // it once the click handler is wired up, so it must be a plain array
  // captured by both closures, not a reactive value (mirrors
  // interactiveGridPattern.ts's cellElements array).
  const cellElements: (HTMLElement | null)[] = new Array(rows * columns).fill(null);

  const cellNodes: DomphyElement[] = [];
  for (let row = 0; row < rows; row += 1) {
    for (let column = 0; column < columns; column += 1) {
      const index = row * columns + column;
      cellNodes.push({
        div: null,
        _key: `cell-${instanceId}-${index}`,
        dataRow: String(row),
        dataCol: String(column),
        ariaHidden: "true",
        _onMount: (node: ElementNode) => {
          cellElements[index] = node.domElement as unknown as HTMLElement;
        },
        style: {
          width: `${cellSize}px`,
          height: `${cellSize}px`,
          boxSizing: "border-box",
          borderWidth: "1px",
          borderStyle: "solid",
          borderColor: (listener: Listener) => themeColor(listener, "shift-3", borderColor),
          // The fill rides on `color` (a themed value, so `missing-color` is
          // satisfied) + a literal `currentColor` background — `currentColor`
          // is exempt from `raw-theme-value` and, unlike a reactive
          // `themeColor()` call, is never flagged by `tone-background-inherit`
          // (that rule only inspects reactive `backgroundColor` functions).
          color: (listener: Listener) => themeColor(listener, "shift-9", fillColor),
          backgroundColor: "currentColor",
          opacity: RESTING_OPACITY,
        } as StyleObject,
      } as DomphyElement);
    }
  }

  const defaultChildren: DomphyElement[] = [
    { h3: "Background Ripple Effect", $: [heading()] } as DomphyElement,
    {
      p: "Click any cell — the ripple radiates outward, farther cells lighting up a beat later.",
      $: [paragraph()],
    } as DomphyElement,
  ];
  const contentChildren = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : defaultChildren;

  return {
    div: [
      {
        div: cellNodes,
        ariaHidden: "true",
        style: {
          position: "absolute",
          inset: 0,
          display: "grid",
          gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
          gridTemplateRows: `repeat(${rows}, ${cellSize}px)`,
          justifyContent: "center",
          alignContent: "center",
        } as StyleObject,
      } as DomphyElement<"div">,
      {
        div: contentChildren,
        style: {
          position: "relative",
          zIndex: 1,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          textAlign: "center",
        } as StyleObject,
      } as DomphyElement<"div">,
    ],
    dataTone: "shift-16",
    _onMount: (node: ElementNode) => {
      if (!interactive || typeof window === "undefined") return;
      const containerElement = node.domElement as HTMLElement | null;
      if (!containerElement) return;

      const triggerRipple = (originRow: number, originColumn: number) => {
        for (let index = 0; index < cellElements.length; index += 1) {
          const cell = cellElements[index];
          if (!cell) continue;
          const cellRow = Math.floor(index / columns);
          const cellColumn = index % columns;
          const distance = Math.hypot(cellRow - originRow, cellColumn - originColumn);
          const delayMs = distance * staggerMs;
          cell.style.animation = "none";
          // Force a reflow so the browser registers the animation removal
          // before it's reapplied — otherwise a second click before the
          // first pulse finishes wouldn't restart the animation.
          void cell.offsetWidth;
          cell.style.animation = `${rippleAnimationName} ${rippleDuration}ms ease-out ${delayMs}ms`;
        }
      };

      const handleClick = (event: MouseEvent) => {
        const target = (event.target as HTMLElement | null)?.closest("[data-row]") as HTMLElement | null;
        if (!target || !containerElement.contains(target)) return;
        const clickedRow = Number(target.getAttribute("data-row"));
        const clickedColumn = Number(target.getAttribute("data-col"));
        if (Number.isNaN(clickedRow) || Number.isNaN(clickedColumn)) return;
        triggerRipple(clickedRow, clickedColumn);
        props.onCellClick?.({ row: clickedRow, column: clickedColumn });
      };

      containerElement.addEventListener("click", handleClick);
      node.addHook("Remove", () => {
        containerElement.removeEventListener("click", handleClick);
      });
    },
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      minHeight: themeSpacing(96),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      [`@keyframes ${rippleAnimationName}`]: rippleKeyframes,
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { backgroundRippleEffect };

← Back to Aceternity UI catalog