Domphy

pixelatedCanvas

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

Implementation notes

Full implementation: hidden sampling canvas resampled on image load/resize, visible canvas redraws every cell each frame with repel/attract/swirl distortion (distance falloff), per-cell offset easing back to rest on pointer-leave, optional jitter, pointer-position lerp smoothing, fps cap, grayscale/tint, square/circle dots, and responsive (ResizeObserver-driven) sizing — all gated by IntersectionObserver. No functional gaps; the animation loop itself can't be exercised in jsdom (no real 2D canvas backend), so its test only verifies structure, matching how every other canvas-driven component in this package is tested.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Pixelated Canvas" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied).
// Renders a source image as a grid of blocky pixel cells on an HTML canvas
// that distorts (repels/attracts/swirls) around the cursor.
//
// Two canvases, same "sample once, redraw many" split this package's
// `asciiArt`/`pixelatedCanvas` share: a hidden offscreen canvas draws the
// loaded image scaled straight down onto a `columns x rows` grid (one
// `getImageData()` pixel per cell — the browser's own image smoothing does
// the per-cell averaging, no manual region loop) whenever the image loads
// or the visible canvas is resized; a visible canvas then redraws every
// cell each frame at its resting position offset by a distance-falloff
// displacement from the tracked pointer.
//
// The pointer position used for the falloff is itself lerped toward the
// raw pointer every frame (`pointerSmoothing`), and each cell's own
// current offset is separately lerped toward its *target* offset — so
// distortion strengthens/eases in as the pointer approaches/leaves a cell
// rather than snapping, per the spec's "eases back to rest" requirement.
// `requestAnimationFrame`, throttled to `frameRateCap`, matches this
// package's other continuous canvas loops (`particles`, `dottedGlowBackground`),
// including the `IntersectionObserver` pause/resume and `ResizeObserver`
// reflow.

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

export type PixelDotShape = "square" | "circle";
export type PixelDistortionMode = "repel" | "attract" | "swirl";
export type PixelObjectFit = "cover" | "contain";

export interface PixelatedCanvasProps {
  /** Source image URL. Defaults to a generic inline placeholder graphic (no network fetch). */
  imageSource?: string;
  /** Canvas CSS width, in px, at the base (non-responsive) size. Defaults to `480`. */
  width?: number;
  /** Canvas CSS height, in px, at the base (non-responsive) size. Defaults to `320`. */
  height?: number;
  /** Grid cell size, in canvas px. Defaults to `5`. */
  cellSize?: number;
  /** Dot shape drawn per cell. Defaults to `"square"`. */
  dotShape?: PixelDotShape;
  /** Fraction (0-1) of each cell's size the dot actually fills. Defaults to `0.9`. */
  dotScale?: number;
  /** Canvas backdrop color family (shows through gaps between dots). Defaults to `"neutral"`. */
  backgroundColor?: ThemeColor;
  /** Desaturates every sampled dot to grayscale. Defaults to `false`. */
  grayscale?: boolean;
  /** Recolors every dot toward this theme color family (multiplies over the sampled luminance) instead of its original hue. */
  tintColor?: ThemeColor;
  /** How cells near the cursor move. Defaults to `"repel"`. */
  distortionMode?: PixelDistortionMode;
  /** Maximum per-cell displacement, in px, at the cursor's center. Defaults to `14`. */
  distortionStrength?: number;
  /** Radius, in px, within which cells are displaced. Defaults to `90`. */
  distortionRadius?: number;
  /** Lerp factor (0-1, higher = snappier) easing the tracked pointer toward the raw pointer each frame. Defaults to `0.18`. */
  pointerSmoothing?: number;
  /** Per-frame random jitter amount, in px, added on top of the distortion offset. Defaults to `0`. */
  jitter?: number;
  /** Target frames per second cap for the redraw loop. Defaults to `60`. */
  frameRateCap?: number;
  /** How the source image is cropped/fit into the cell grid. Defaults to `"cover"`. */
  objectFit?: PixelObjectFit;
  /** Scales the canvas to fill its container's measured width (aspect ratio locked to `width`/`height`) instead of a fixed pixel size. Defaults to `true`. */
  responsive?: boolean;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

interface CellSample {
  red: number;
  green: number;
  blue: number;
  restX: number;
  restY: number;
  offsetX: number;
  offsetY: number;
  jitterX: number;
  jitterY: number;
}

// Generic abstract placeholder graphic — an inline SVG data URI, no network
// fetch and no real photo (same idiom `pixelImage.ts`/`asciiArt.ts` use for
// their own default demo imagery elsewhere in this package).
const PLACEHOLDER_IMAGE_MARKUP =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200">' +
  '<rect width="300" height="200" fill="#0f172a"/>' +
  '<circle cx="210" cy="70" r="50" fill="#f59e0b"/>' +
  '<path d="M0 160 L90 90 L150 140 L210 80 L300 150 L300 200 L0 200 Z" fill="#334155"/>' +
  "</svg>";
const PLACEHOLDER_IMAGE_URI = `data:image/svg+xml,${encodeURIComponent(PLACEHOLDER_IMAGE_MARKUP)}`;

function drawSourceIntoSampleCanvas(
  context: CanvasRenderingContext2D,
  image: HTMLImageElement,
  columns: number,
  rows: number,
  objectFit: PixelObjectFit,
): void {
  context.imageSmoothingEnabled = true;
  context.clearRect(0, 0, columns, rows);
  const naturalWidth = image.naturalWidth || columns;
  const naturalHeight = image.naturalHeight || rows;

  if (objectFit === "contain") {
    context.drawImage(image, 0, 0, naturalWidth, naturalHeight, 0, 0, columns, rows);
    return;
  }

  const sourceAspect = naturalWidth / naturalHeight;
  const targetAspect = columns / rows;
  let sourceX = 0;
  let sourceY = 0;
  let sourceWidth = naturalWidth;
  let sourceHeight = naturalHeight;
  if (sourceAspect > targetAspect) {
    sourceWidth = naturalHeight * targetAspect;
    sourceX = (naturalWidth - sourceWidth) / 2;
  } else {
    sourceHeight = naturalWidth / targetAspect;
    sourceY = (naturalHeight - sourceHeight) / 2;
  }
  context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, columns, rows);
}

function buildCellSamples(
  context: CanvasRenderingContext2D,
  columns: number,
  rows: number,
  cellSize: number,
): CellSample[] {
  const pixels = context.getImageData(0, 0, columns, rows).data;
  const cells: CellSample[] = new Array(columns * rows);
  for (let row = 0; row < rows; row += 1) {
    for (let column = 0; column < columns; column += 1) {
      const index = row * columns + column;
      const offset = index * 4;
      cells[index] = {
        red: pixels[offset],
        green: pixels[offset + 1],
        blue: pixels[offset + 2],
        restX: column * cellSize + cellSize / 2,
        restY: row * cellSize + cellSize / 2,
        offsetX: 0,
        offsetY: 0,
        jitterX: 0,
        jitterY: 0,
      };
    }
  }
  return cells;
}

function applyGrayscaleAndTint(
  red: number,
  green: number,
  blue: number,
  grayscale: boolean,
  tint: { red: number; green: number; blue: number } | null,
): [number, number, number] {
  let outputRed = red;
  let outputGreen = green;
  let outputBlue = blue;
  if (grayscale || tint) {
    const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
    outputRed = luminance;
    outputGreen = luminance;
    outputBlue = luminance;
  }
  if (tint) {
    outputRed = (outputRed / 255) * tint.red;
    outputGreen = (outputGreen / 255) * tint.green;
    outputBlue = (outputBlue / 255) * tint.blue;
  }
  return [outputRed, outputGreen, outputBlue];
}

function tokenToRgb(hexToken: string): { red: number; green: number; blue: number } {
  const hex = hexToken.replace("#", "");
  const isShort = hex.length === 3;
  const red = parseInt(isShort ? hex[0] + hex[0] : hex.slice(0, 2), 16) || 0;
  const green = parseInt(isShort ? hex[1] + hex[1] : hex.slice(2, 4), 16) || 0;
  const blue = parseInt(isShort ? hex[2] + hex[2] : hex.slice(4, 6), 16) || 0;
  return { red, green, blue };
}

let pixelatedCanvasInstanceCounter = 0;

/**
 * Renders a source image as a grid of blocky pixel cells that repel,
 * attract, or swirl away from the cursor. Call with no arguments for a
 * working demo using a generic placeholder graphic.
 */
function pixelatedCanvas(props: PixelatedCanvasProps = {}): DomphyElement<"div"> {
  const instanceId = ++pixelatedCanvasInstanceCounter;
  const imageSource = props.imageSource ?? PLACEHOLDER_IMAGE_URI;
  const baseWidth = Math.max(16, props.width ?? 480);
  const baseHeight = Math.max(16, props.height ?? 320);
  const cellSize = Math.max(1, props.cellSize ?? 5);
  const dotShape = props.dotShape ?? "square";
  const dotScale = Math.min(1, Math.max(0.1, props.dotScale ?? 0.9));
  const backgroundColorFamily = props.backgroundColor ?? "neutral";
  const grayscale = props.grayscale ?? false;
  const tintColor = props.tintColor;
  const distortionMode = props.distortionMode ?? "repel";
  const distortionStrength = props.distortionStrength ?? 14;
  const distortionRadius = Math.max(1, props.distortionRadius ?? 90);
  const pointerSmoothing = Math.min(1, Math.max(0.01, props.pointerSmoothing ?? 0.18));
  const jitter = props.jitter ?? 0;
  const frameRateCap = Math.max(1, props.frameRateCap ?? 60);
  const objectFit = props.objectFit ?? "cover";
  const responsive = props.responsive ?? true;

  const canvasElement = {
    canvas: null,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      display: "block",
      width: "100%",
      height: "100%",
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const canvas = node.domElement as HTMLCanvasElement | null;
      const containerElement = canvas?.parentElement ?? null;
      if (!canvas || !containerElement) return;
      const context = canvas.getContext("2d");
      // 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.
      if (!context) return;

      const sampleCanvas = document.createElement("canvas");
      const sampleContext = sampleCanvas.getContext("2d", {
        willReadFrequently: true,
      } as CanvasRenderingContext2DSettings);

      const backgroundToken = (() => {
        try {
          return themeColorToken(node, "inherit", backgroundColorFamily);
        } catch {
          return "#000000";
        }
      })();
      const tintRgb = tintColor
        ? (() => {
            try {
              return tokenToRgb(themeColorToken(node, "shift-9", tintColor));
            } catch {
              return null;
            }
          })()
        : null;

      let devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      let cssWidth = baseWidth;
      let cssHeight = baseHeight;
      let columns = Math.max(1, Math.round(cssWidth / cellSize));
      let rows = Math.max(1, Math.round(cssHeight / cellSize));
      let cells: CellSample[] = [];
      let image: HTMLImageElement | null = null;
      let imageLoaded = false;

      let rawPointerX = -distortionRadius * 2;
      let rawPointerY = -distortionRadius * 2;
      let smoothPointerX = rawPointerX;
      let smoothPointerY = rawPointerY;
      let pointerActive = false;

      let animationFrameId: number | null = null;
      let resizeObserver: ResizeObserver | null = null;
      let intersectionObserver: IntersectionObserver | null = null;
      let lastFrameTime = 0;

      function resample(): void {
        if (!sampleContext || !image || !imageLoaded) return;
        columns = Math.max(1, Math.round(cssWidth / cellSize));
        rows = Math.max(1, Math.round(cssHeight / cellSize));
        sampleCanvas.width = columns;
        sampleCanvas.height = rows;
        try {
          drawSourceIntoSampleCanvas(sampleContext, image, columns, rows, objectFit);
          cells = buildCellSamples(sampleContext, columns, rows, cellSize);
        } catch {
          // Cross-origin image without CORS headers taints the canvas —
          // `getImageData()` throws. Leave the grid empty rather than crash.
          cells = [];
        }
      }

      function resizeCanvas(): void {
        if (responsive) {
          const rect = containerElement!.getBoundingClientRect();
          cssWidth = rect.width > 0 ? rect.width : baseWidth;
          cssHeight = cssWidth * (baseHeight / baseWidth);
        } else {
          cssWidth = baseWidth;
          cssHeight = baseHeight;
        }
        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);
        resample();
      }

      function computeDisplacement(cell: CellSample): { x: number; y: number } {
        const deltaX = cell.restX - smoothPointerX;
        const deltaY = cell.restY - smoothPointerY;
        const distance = Math.hypot(deltaX, deltaY);
        if (!pointerActive || distance >= distortionRadius || distance < 0.001) {
          return { x: 0, y: 0 };
        }
        const falloff = 1 - distance / distortionRadius;
        const magnitude = distortionStrength * falloff;
        if (distortionMode === "attract") {
          return { x: (-deltaX / distance) * magnitude, y: (-deltaY / distance) * magnitude };
        }
        if (distortionMode === "swirl") {
          // Tangential displacement — perpendicular to the radius vector.
          return { x: (-deltaY / distance) * magnitude, y: (deltaX / distance) * magnitude };
        }
        // repel
        return { x: (deltaX / distance) * magnitude, y: (deltaY / distance) * magnitude };
      }

      function drawFrame(): void {
        context!.clearRect(0, 0, cssWidth, cssHeight);
        context!.fillStyle = backgroundToken;
        context!.fillRect(0, 0, cssWidth, cssHeight);

        const dotSize = cellSize * dotScale;
        for (const cell of cells) {
          const target = computeDisplacement(cell);
          cell.offsetX += (target.x - cell.offsetX) * 0.25;
          cell.offsetY += (target.y - cell.offsetY) * 0.25;
          if (jitter > 0) {
            cell.jitterX = (Math.random() - 0.5) * jitter;
            cell.jitterY = (Math.random() - 0.5) * jitter;
          }

          const [red, green, blue] = applyGrayscaleAndTint(cell.red, cell.green, cell.blue, grayscale, tintRgb);
          context!.fillStyle = `rgb(${red | 0}, ${green | 0}, ${blue | 0})`;

          const drawX = cell.restX + cell.offsetX + cell.jitterX;
          const drawY = cell.restY + cell.offsetY + cell.jitterY;
          if (dotShape === "circle") {
            context!.beginPath();
            context!.arc(drawX, drawY, dotSize / 2, 0, Math.PI * 2);
            context!.fill();
          } else {
            context!.fillRect(drawX - dotSize / 2, drawY - dotSize / 2, dotSize, dotSize);
          }
        }
      }

      function tick(time: number): void {
        // Belt-and-suspenders: this loop is otherwise only stopped by the
        // `IntersectionObserver` callback (skipped entirely when
        // `IntersectionObserver` isn't available — see `startLoop()`'s
        // caller below) or the node's own "Remove" hook. Neither is
        // guaranteed to fire in every host (e.g. a raw `innerHTML = ""`
        // DOM wipe never runs Domphy's removal lifecycle) — bail out
        // without rescheduling once the canvas itself is detached, so the
        // loop can't outlive its element.
        if (!canvas!.isConnected) {
          animationFrameId = null;
          return;
        }
        const minFrameInterval = 1000 / frameRateCap;
        if (time - lastFrameTime >= minFrameInterval) {
          lastFrameTime = time;
          smoothPointerX += (rawPointerX - smoothPointerX) * pointerSmoothing;
          smoothPointerY += (rawPointerY - smoothPointerY) * pointerSmoothing;
          drawFrame();
        }
        animationFrameId = window.requestAnimationFrame(tick);
      }

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

      function handlePointerMove(event: PointerEvent): void {
        const rect = containerElement!.getBoundingClientRect();
        rawPointerX = event.clientX - rect.left;
        rawPointerY = event.clientY - rect.top;
        pointerActive = true;
      }
      function handlePointerLeave(): void {
        pointerActive = false;
        rawPointerX = -distortionRadius * 2;
        rawPointerY = -distortionRadius * 2;
      }

      resizeCanvas();
      if (sampleContext) {
        image = new Image();
        image.crossOrigin = "anonymous";
        image.onload = () => {
          imageLoaded = true;
          resample();
        };
        image.onerror = () => {
          // Leave the grid empty (background-only canvas) on load failure.
        };
        image.src = imageSource;
      }

      containerElement.addEventListener("pointermove", handlePointerMove);
      containerElement.addEventListener("pointerleave", handlePointerLeave);

      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();
        containerElement.removeEventListener("pointermove", handlePointerMove);
        containerElement.removeEventListener("pointerleave", handlePointerLeave);
      });
    },
  } as unknown as DomphyElement<"canvas">;

  return {
    div: [canvasElement],
    style: {
      position: "relative",
      display: "block",
      width: responsive ? "100%" : `${baseWidth}px`,
      aspectRatio: `${baseWidth} / ${baseHeight}`,
      overflow: "hidden",
      borderRadius: themeSpacing(3),
      ...(props.style ?? {}),
    } as StyleObject,
  } as DomphyElement<"div">;
}

export { pixelatedCanvas };

← Back to Aceternity UI catalog