Domphy

webcamPixelGrid

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

Implementation notes

Fully implemented against the spec using real browser APIs (navigator.mediaDevices.getUserMedia, a hidden <video>, and a canvas redraw loop with onWebcamReady/onWebcamError callbacks and graceful no-camera/permission-denied fallback to a plain dark placeholder, matching the spec's own researchNote). Marked 'partial' for two implementer choices the spec itself flagged as open/unverified: (1) elevation is rendered as a 2D canvas shading trick (small upward pixel offset + brightness boost) rather than true per-tile CSS 3D perspective transforms, chosen because redrawing 64x48=3072 individually-transformed DOM tiles every frame would be far more expensive than one canvas redraw; (2) the visual result could not be verified against a live camera in this sandboxed/headless environment (no camera hardware, no canvas npm package for jsdom's 2D context), so only structural/fallback behavior was exercised by tests, not live motion.

Status: partial · Reference: Aceternity UI original

// Aceternity UI "Webcam Pixel Grid" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// live webcam feed downsampled into a grid of colored pixel tiles rendered on
// a canvas, with a per-tile "elevation" pop driven by how much that cell's
// color has changed since the last frame — a cheap 2D stand-in for a full 3D
// relief effect.
//
// Two canvases, the same "sample once, redraw many" split this package's
// `pixelatedCanvas.ts` already uses for a *static* source image: here the
// source is a live `<video>` element instead of a loaded `<img>`, redrawn
// into a `gridCols x gridRows` offscreen canvas every frame (the browser's own
// image smoothing does the per-cell averaging, no manual region loop), then
// `getImageData()` reads back one RGB triplet per cell. Comparing each cell's
// color against its own previous-frame color gives a per-cell motion delta;
// an asymmetric low-pass filter (fast rise toward a higher delta, slow decay
// back down) turns that into a smoothed "elevation" value in `[0, 1]`, which
// the visible canvas renders as a small upward pixel offset plus a brightness
// boost — a 2D shading trick standing in for a true CSS 3D perspective
// transform per tile (see this file's `fidelityNotes` in the port manifest:
// redrawing thousands of individually-transformed DOM tiles at 64x48
// resolution every frame would be far more expensive than one canvas redraw,
// and the spec itself flags the rendering surface as an open implementation
// choice).
//
// `video`/`canvas` register themselves into shared closure refs and each
// calls a guarded `trySetup()` once both exist — the same order-independent
// pairing idiom `tracingBeam.ts` uses for its own two sibling SVG nodes —
// rather than assuming sibling mount order.
//
// `navigator.mediaDevices.getUserMedia` is requested once both refs are
// ready; a missing API, a rejected permission prompt, or no camera device all
// resolve to the same graceful fallback — the sampling loop never starts and
// the container reads as a plain dark placeholder, matching the spec's own
// researchNote about the no-camera case in this environment.

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

export type WebcamPixelGridColorMode = "webcam" | "monochrome";

export interface WebcamPixelGridProps {
  /** Sampling grid columns. Defaults to `64`. */
  gridCols?: number;
  /** Sampling grid rows. Defaults to `48`. */
  gridRows?: number;
  /** How strongly frame-to-frame color change drives the elevation pop, `0-1`. Defaults to `0.6`. */
  motionSensitivity?: number;
  /** Maximum per-tile upward pixel offset at full elevation. Defaults to `15`. */
  maxElevation?: number;
  /** Low-pass smoothing factor easing elevation toward its target each frame, `0-1` (higher = snappier). Defaults to `0.1`. */
  elevationSmoothing?: number;
  /** `"webcam"` samples true per-tile color; `"monochrome"` recolors every tile toward `monochromeColor` at the sampled brightness. Defaults to `"webcam"`. */
  colorMode?: WebcamPixelGridColorMode;
  /** Theme color family used for every tile in monochrome mode. Defaults to `"success"` (a bright green family). */
  monochromeColor?: ThemeColor;
  /** Theme color family for the container backdrop showing through tile gaps. Defaults to `"neutral"`. */
  backgroundColor?: ThemeColor;
  /** Theme color family for each tile's outline. Defaults to `"neutral"`. */
  borderColor?: ThemeColor;
  /** Opacity of each tile's outline, `0-1`. Defaults to `0.15`. */
  borderOpacity?: number;
  /** Flips the sampled feed horizontally, like a mirror/selfie view. Defaults to `true`. */
  mirror?: boolean;
  /** Fraction of each cell reserved as a gap between tiles, `0-1`. Defaults to `0.12`. */
  gapRatio?: number;
  /** Inverts every sampled color (`255 - channel`). Defaults to `false`. */
  invertColors?: boolean;
  /** Darkens every sampled color, `0` (no change) to `1` (black). Defaults to `0`. */
  darken?: number;
  /** Fires once the webcam stream is playing. */
  onWebcamReady?: () => void;
  /** Fires when the webcam can't be accessed (no API, no device, or denied permission). */
  onWebcamError?: (error: unknown) => void;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

function clampUnit(value: number): number {
  if (Number.isNaN(value)) return 0;
  return Math.min(1, Math.max(0, value));
}

/** Same small hex->rgb helper `pixelatedCanvas.ts` uses for its own resolved theme tokens. */
function hexTokenToRgb(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 webcamPixelGridInstanceCounter = 0;

/**
 * A live webcam feed downsampled into a grid of colored pixel tiles, with
 * motion-driven per-tile elevation for a subtle relief look. Call with no
 * arguments for a working demo — falls back to a plain dark placeholder
 * wherever no camera is available or permission is denied.
 */
function webcamPixelGrid(props: WebcamPixelGridProps = {}): DomphyElement<"div"> {
  ++webcamPixelGridInstanceCounter;
  const gridCols = Math.max(2, Math.round(props.gridCols ?? 64));
  const gridRows = Math.max(2, Math.round(props.gridRows ?? 48));
  const motionSensitivity = clampUnit(props.motionSensitivity ?? 0.6);
  const maxElevation = Math.max(0, props.maxElevation ?? 15);
  const elevationSmoothing = Math.min(1, Math.max(0.01, props.elevationSmoothing ?? 0.1));
  const colorMode = props.colorMode ?? "webcam";
  const monochromeColor = props.monochromeColor ?? "success";
  const backgroundColor = props.backgroundColor ?? "neutral";
  const borderColor = props.borderColor ?? "neutral";
  const borderOpacity = clampUnit(props.borderOpacity ?? 0.15);
  const mirror = props.mirror ?? true;
  const gapRatio = Math.min(0.6, Math.max(0, props.gapRatio ?? 0.12));
  const invertColors = props.invertColors ?? false;
  const darken = clampUnit(props.darken ?? 0);

  let videoDomElement: HTMLVideoElement | null = null;
  let canvasDomElement: HTMLCanvasElement | null = null;
  let removeTeardown: (() => void) | null = null;

  function trySetup(node: ElementNode): void {
    if (!videoDomElement || !canvasDomElement) return;
    if (removeTeardown || typeof window === "undefined") return;

    let mediaStream: MediaStream | null = null;
    let streamActive = false;

    // Requesting the camera never depends on 2D canvas support, so this runs
    // before the canvas-context guard below — a headless/test runtime with no
    // `canvas` npm package installed (canvas draws are unavailable) should
    // still resolve `onWebcamReady`/`onWebcamError` from the real API.
    const hasGetUserMedia =
      typeof navigator !== "undefined" &&
      !!navigator.mediaDevices &&
      typeof navigator.mediaDevices.getUserMedia === "function";

    if (!hasGetUserMedia) {
      props.onWebcamError?.(new Error("Camera access is not available in this environment."));
    } else {
      navigator.mediaDevices
        .getUserMedia({
          video: { width: { ideal: gridCols * 8 }, height: { ideal: gridRows * 8 } },
          audio: false,
        })
        .then((stream) => {
          mediaStream = stream;
          videoDomElement!.srcObject = stream;
          return videoDomElement!.play();
        })
        .then(() => {
          streamActive = true;
          props.onWebcamReady?.();
        })
        .catch((error: unknown) => {
          props.onWebcamError?.(error);
        });
    }

    const containerElement = canvasDomElement.parentElement;
    if (!containerElement) {
      removeTeardown = () => {
        mediaStream?.getTracks().forEach((track) => track.stop());
        removeTeardown = null;
      };
      return;
    }
    const context = canvasDomElement.getContext("2d");
    if (!context) {
      removeTeardown = () => {
        mediaStream?.getTracks().forEach((track) => track.stop());
        removeTeardown = null;
      };
      return;
    }

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

    const backgroundToken = (() => {
      try {
        return themeColorToken(node, "inherit", backgroundColor);
      } catch {
        return "#000000";
      }
    })();
    const borderToken = (() => {
      try {
        return themeColorToken(node, "shift-6", borderColor);
      } catch {
        return "#666666";
      }
    })();
    const monochromeRgb = (() => {
      try {
        return hexTokenToRgb(themeColorToken(node, "shift-9", monochromeColor));
      } catch {
        return { red: 57, green: 255, blue: 20 };
      }
    })();

    const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
    let cssWidth = 0;
    let cssHeight = 0;

    const cellCount = gridCols * gridRows;
    const previousRed = new Float32Array(cellCount);
    const previousGreen = new Float32Array(cellCount);
    const previousBlue = new Float32Array(cellCount);
    const elevation = new Float32Array(cellCount);
    let hasPreviousFrame = false;

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

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

    function sampleFrame(): Uint8ClampedArray | null {
      if (!sampleContext) return null;
      sampleContext.imageSmoothingEnabled = true;
      sampleContext.save();
      if (mirror) {
        sampleContext.translate(gridCols, 0);
        sampleContext.scale(-1, 1);
      }
      try {
        sampleContext.drawImage(videoDomElement!, 0, 0, gridCols, gridRows);
      } catch {
        sampleContext.restore();
        return null;
      }
      sampleContext.restore();
      return sampleContext.getImageData(0, 0, gridCols, gridRows).data;
    }

    function processColor(red: number, green: number, blue: number): [number, number, number] {
      let outputRed = red;
      let outputGreen = green;
      let outputBlue = blue;
      if (colorMode === "monochrome") {
        const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
        outputRed = monochromeRgb.red * luminance;
        outputGreen = monochromeRgb.green * luminance;
        outputBlue = monochromeRgb.blue * luminance;
      }
      if (invertColors) {
        outputRed = 255 - outputRed;
        outputGreen = 255 - outputGreen;
        outputBlue = 255 - outputBlue;
      }
      if (darken > 0) {
        outputRed *= 1 - darken;
        outputGreen *= 1 - darken;
        outputBlue *= 1 - darken;
      }
      return [outputRed, outputGreen, outputBlue];
    }

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

      const cellWidth = cssWidth / gridCols;
      const cellHeight = cssHeight / gridRows;
      const tileWidth = cellWidth * (1 - gapRatio);
      const tileHeight = cellHeight * (1 - gapRatio);

      for (let row = 0; row < gridRows; row += 1) {
        for (let column = 0; column < gridCols; column += 1) {
          const index = row * gridCols + column;
          const offset = index * 4;
          const sampledRed = pixels[offset];
          const sampledGreen = pixels[offset + 1];
          const sampledBlue = pixels[offset + 2];

          const delta = hasPreviousFrame
            ? (Math.abs(sampledRed - previousRed[index]) +
                Math.abs(sampledGreen - previousGreen[index]) +
                Math.abs(sampledBlue - previousBlue[index])) /
              (255 * 3)
            : 0;
          const target = clampUnit(delta * motionSensitivity * 4);
          // Asymmetric low-pass: rises toward a higher target several times
          // faster than it decays back down, so motion pops quickly and
          // settles smoothly rather than jittering frame to frame.
          const rate = target > elevation[index] ? Math.min(1, elevationSmoothing * 5) : elevationSmoothing;
          elevation[index] += (target - elevation[index]) * rate;

          previousRed[index] = sampledRed;
          previousGreen[index] = sampledGreen;
          previousBlue[index] = sampledBlue;

          const [red, green, blue] = processColor(sampledRed, sampledGreen, sampledBlue);
          const boost = elevation[index] * 0.35;
          const litRed = red + (255 - red) * boost;
          const litGreen = green + (255 - green) * boost;
          const litBlue = blue + (255 - blue) * boost;

          const tileX = column * cellWidth + (cellWidth - tileWidth) / 2;
          const tileY = row * cellHeight + (cellHeight - tileHeight) / 2 - elevation[index] * maxElevation;

          context!.fillStyle = `rgb(${litRed | 0}, ${litGreen | 0}, ${litBlue | 0})`;
          context!.fillRect(tileX, tileY, tileWidth, tileHeight);

          if (borderOpacity > 0) {
            context!.globalAlpha = borderOpacity;
            context!.strokeStyle = borderToken;
            context!.lineWidth = 1;
            context!.strokeRect(tileX + 0.5, tileY + 0.5, tileWidth - 1, tileHeight - 1);
            context!.globalAlpha = 1;
          }
        }
      }
      hasPreviousFrame = true;
    }

    function tick(): void {
      if (streamActive) drawFrame();
      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;
    }

    resizeCanvas();
    context.fillStyle = backgroundToken;
    context.fillRect(0, 0, cssWidth, cssHeight);

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

    removeTeardown = () => {
      stopLoop();
      resizeObserver?.disconnect();
      intersectionObserver?.disconnect();
      mediaStream?.getTracks().forEach((track) => track.stop());
      removeTeardown = null;
    };
  }

  const videoElement: DomphyElement<"video"> = {
    video: null,
    muted: true,
    autoPlay: true,
    playsInline: true,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: { display: "none" } as StyleObject,
    _onMount: (node: ElementNode) => {
      videoDomElement = node.domElement as HTMLVideoElement;
      trySetup(node);
    },
    _onRemove: () => {
      videoDomElement = null;
      removeTeardown?.();
    },
  } as unknown as DomphyElement<"video">;

  const canvasElement = {
    canvas: null,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      inset: 0,
      width: "100%",
      height: "100%",
      display: "block",
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      canvasDomElement = node.domElement as HTMLCanvasElement;
      trySetup(node);
    },
    _onRemove: () => {
      canvasDomElement = null;
      removeTeardown?.();
    },
  } as unknown as DomphyElement<"canvas">;

  return {
    div: [videoElement, canvasElement],
    dataTone: "shift-16",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      width: "100%",
      aspectRatio: `${gridCols} / ${gridRows}`,
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { webcamPixelGrid };

← Back to Aceternity UI catalog