Domphy

ditherShader

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

Implementation notes

All 4 dither modes (bayer/halftone/noise/crosshatch) x 4 color modes (grayscale/original/duotone/custom) implemented, plus animated phase-drift via rAF gated by IntersectionObserver, plus brightness/contrast/threshold controls. Uses a standard 4x4 Bayer matrix per the spec's own 'medium confidence' allowance. colorMode='custom' resolves through theme color families (like duotone) rather than arbitrary hex, since Domphy's doctor forbids raw color literals in style — this is a deliberate, documented scope narrowing from a literally-free-RGB 'custom' mode, not a missing capability.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Dither Shader" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Converts a
// source photo into a real-time ordered-dithering render (Bayer/halftone/
// noise/crosshatch) for a retro, pixel-art look.
//
// Same "sample once onto a hidden downscale canvas, redraw onto the visible
// one" split this package's `pixelatedCanvas`/`asciiArt` already use: a
// hidden canvas draws the loaded image scaled down to `columns x rows`
// (one `getImageData()` pixel per grid cell — the browser's own bilinear
// downsample does the per-cell averaging), and the visible canvas redraws
// every cell each pass using one of 4 per-cell decision rules:
//
// - "bayer": classic ordered dithering against a 4x4 Bayer threshold matrix.
// - "noise": the same threshold-vs-luminance test, but the per-cell
//   threshold comes from a deterministic pseudo-random hash instead of a
//   fixed matrix.
// - "halftone": no binary on/off — each cell draws a variable-radius dot,
//   radius scaling with the cell's darkness (classic newsprint halftone).
// - "crosshatch": each cell quantizes to one of 4 density bands (none / one
//   diagonal / crossed diagonals / filled), band count standing in for tone.
//
// `animated` re-runs the exact same per-cell pass every frame with the
// matrix/noise/jitter phase offset by elapsed time (a `requestAnimationFrame`
// loop, throttled and gated by `IntersectionObserver`, matching this
// package's other continuous canvas loops) instead of doing a single
// synchronous pass on load.

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

export type DitherPatternMode = "bayer" | "halftone" | "noise" | "crosshatch";
export type DitherColorMode = "grayscale" | "original" | "duotone" | "custom";

export interface DitherShaderProps {
  /** Source image URL. Defaults to a generic inline placeholder graphic (no network fetch). */
  src?: string;
  /** Accessible label for the rendered image. Defaults to `"Dithered image"`. */
  alt?: string;
  /** Dither cell/block size, in output px. Defaults to `4`. */
  gridSize?: number;
  /** Ordered-dither pattern rule. Defaults to `"bayer"`. */
  ditherMode?: DitherPatternMode;
  /** How on/off cells are colored. Defaults to `"grayscale"`. */
  colorMode?: DitherColorMode;
  /** "Ink" color family for `"duotone"`/`"custom"` color modes. Defaults to `"neutral"`. */
  primaryColor?: ThemeColor;
  /** "Paper" color family for `"duotone"`/`"custom"` color modes. Defaults to `"neutral"`. */
  secondaryColor?: ThemeColor;
  /** Luminance threshold (0-1) separating "on" from "off" cells. Defaults to `0.5`. */
  threshold?: number;
  /** Additive brightness adjustment, roughly -1 to 1. Defaults to `0`. */
  brightness?: number;
  /** Contrast adjustment, roughly -1 to 1. Defaults to `0`. */
  contrast?: number;
  /** Subtly drifts the dither pattern every frame instead of rendering once. Defaults to `false`. */
  animated?: boolean;
  /** Phase units advanced per second while `animated`. Defaults to `1`. */
  animationSpeed?: number;
  /** Output CSS width, in px. Defaults to `480`. */
  width?: number;
  /** Output CSS height, in px. Defaults to `320`. */
  height?: number;
  /** Passthrough style merged onto the outer wrapper. */
  style?: StyleObject;
}

interface DitherCellSample {
  red: number;
  green: number;
  blue: number;
  luminance: number;
}

// A standard 4x4 Bayer ordered-dither threshold matrix (values 0-15).
const BAYER_MATRIX_4X4: readonly (readonly number[])[] = [
  [0, 8, 2, 10],
  [12, 4, 14, 6],
  [3, 11, 1, 9],
  [15, 7, 13, 5],
];

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 clamp01(value: number): number {
  return Math.min(1, Math.max(0, value));
}

// Deterministic per-cell hash noise (classic GLSL "sin fract" trick) — cheap,
// stable across frames unless the seed changes.
function pseudoRandom(column: number, row: number, seed: number): number {
  const value = Math.sin(column * 127.1 + row * 311.7 + seed * 74.7) * 43758.5453;
  return value - Math.floor(value);
}

function applyBrightnessContrast(luminance: number, brightness: number, contrast: number): number {
  const brightened = luminance + brightness;
  const contrastFactor = 1 + contrast;
  return clamp01((brightened - 0.5) * contrastFactor + 0.5);
}

function drawSourceIntoSampleCanvas(
  context: CanvasRenderingContext2D,
  image: HTMLImageElement,
  columns: number,
  rows: number,
): void {
  context.imageSmoothingEnabled = true;
  context.clearRect(0, 0, columns, rows);
  const naturalWidth = image.naturalWidth || columns;
  const naturalHeight = image.naturalHeight || rows;
  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,
): DitherCellSample[] {
  const pixels = context.getImageData(0, 0, columns, rows).data;
  const cells: DitherCellSample[] = new Array(columns * rows);
  for (let index = 0; index < columns * rows; index += 1) {
    const offset = index * 4;
    const red = pixels[offset] ?? 0;
    const green = pixels[offset + 1] ?? 0;
    const blue = pixels[offset + 2] ?? 0;
    cells[index] = {
      red,
      green,
      blue,
      luminance: (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255,
    };
  }
  return cells;
}

interface ResolvedDitherColors {
  onToken: string;
  offToken: string;
}

function resolveDitherColors(
  node: ElementNode,
  colorMode: DitherColorMode,
  primaryColorFamily: ThemeColor,
  secondaryColorFamily: ThemeColor,
): ResolvedDitherColors {
  try {
    if (colorMode === "grayscale") {
      return {
        onToken: themeColorToken(node, "shift-16", "neutral"),
        offToken: themeColorToken(node, "shift-0", "neutral"),
      };
    }
    if (colorMode === "original") {
      // "on" is overridden per-cell with the sampled source color; the
      // token here is only the paper/background fill.
      return {
        onToken: themeColorToken(node, "shift-16", "neutral"),
        offToken: themeColorToken(node, "shift-1", "neutral"),
      };
    }
    // duotone / custom — both resolve through theme color families rather
    // than arbitrary hex, since Domphy forbids raw color literals. "custom"
    // simply means the caller supplied both families explicitly.
    return {
      onToken: themeColorToken(node, "shift-13", primaryColorFamily),
      offToken: themeColorToken(node, "shift-2", secondaryColorFamily),
    };
  } catch {
    return { onToken: "#161616", offToken: "#f2f2f2" };
  }
}

/**
 * Renders a source image as a real-time ordered-dithering pattern (Bayer,
 * halftone, noise, or crosshatch) on a canvas. Call with no arguments for a
 * working demo using a generic placeholder graphic.
 */
function ditherShader(props: DitherShaderProps = {}): DomphyElement<"div"> {
  const imageSource = props.src ?? PLACEHOLDER_IMAGE_URI;
  const altText = props.alt ?? "Dithered image";
  const gridSize = Math.max(1, props.gridSize ?? 4);
  const ditherMode = props.ditherMode ?? "bayer";
  const colorMode = props.colorMode ?? "grayscale";
  const primaryColorFamily = props.primaryColor ?? "neutral";
  const secondaryColorFamily = props.secondaryColor ?? "neutral";
  const threshold = clamp01(props.threshold ?? 0.5);
  const brightness = props.brightness ?? 0;
  const contrast = props.contrast ?? 0;
  const animated = props.animated ?? false;
  const animationSpeed = props.animationSpeed ?? 1;
  const outputWidth = Math.max(16, props.width ?? 480);
  const outputHeight = Math.max(16, props.height ?? 320);

  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 wrapperElement = canvas?.parentElement ?? null;
      if (!canvas || !wrapperElement) return;
      const context = canvas.getContext("2d");
      // Headless/test runtimes without a real 2D canvas backend resolve
      // getContext to null rather than throwing — bail before starting.
      if (!context) return;

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

      const colors = resolveDitherColors(node, colorMode, primaryColorFamily, secondaryColorFamily);

      const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.floor(outputWidth * devicePixelRatio));
      canvas.height = Math.max(1, Math.floor(outputHeight * devicePixelRatio));
      context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);

      const columns = Math.max(1, Math.round(outputWidth / gridSize));
      const rows = Math.max(1, Math.round(outputHeight / gridSize));
      sampleCanvas.width = columns;
      sampleCanvas.height = rows;

      let cells: DitherCellSample[] = [];
      let image: HTMLImageElement | null = null;
      let imageLoaded = false;
      let animationFrameId: number | null = null;
      let intersectionObserver: IntersectionObserver | null = null;
      const startTimeMs = performance.now();

      function resample(): void {
        if (!sampleContext || !image || !imageLoaded) return;
        try {
          drawSourceIntoSampleCanvas(sampleContext, image, columns, rows);
          cells = buildCellSamples(sampleContext, columns, rows);
        } catch {
          // Cross-origin image without CORS headers taints the canvas —
          // getImageData() throws. Leave the grid empty rather than crash.
          cells = [];
        }
      }

      function drawFrame(phase: number): void {
        context!.clearRect(0, 0, outputWidth, outputHeight);
        context!.fillStyle = colors.offToken;
        context!.fillRect(0, 0, outputWidth, outputHeight);

        const phaseRowOffset = Math.floor(phase) & 3;
        const phaseColumnOffset = Math.floor(phase * 1.3) & 3;

        for (let row = 0; row < rows; row += 1) {
          for (let column = 0; column < columns; column += 1) {
            const cell = cells[row * columns + column];
            if (!cell) continue;
            const adjustedLuminance = applyBrightnessContrast(cell.luminance, brightness, contrast);
            const cellX = column * gridSize;
            const cellY = row * gridSize;

            const onFillStyle =
              colorMode === "original"
                ? `rgb(${cell.red | 0}, ${cell.green | 0}, ${cell.blue | 0})`
                : colors.onToken;

            if (ditherMode === "bayer") {
              const matrixRow = (row + phaseRowOffset) & 3;
              const matrixColumn = (column + phaseColumnOffset) & 3;
              const matrixValue = ((BAYER_MATRIX_4X4[matrixRow]?.[matrixColumn] ?? 0) + 0.5) / 16;
              const localThreshold = clamp01(threshold + (matrixValue - 0.5));
              if (adjustedLuminance <= localThreshold) {
                context!.fillStyle = onFillStyle;
                context!.fillRect(cellX, cellY, gridSize, gridSize);
              }
            } else if (ditherMode === "noise") {
              const noiseValue = pseudoRandom(column, row, phase);
              const localThreshold = clamp01(threshold + (noiseValue - 0.5));
              if (adjustedLuminance <= localThreshold) {
                context!.fillStyle = onFillStyle;
                context!.fillRect(cellX, cellY, gridSize, gridSize);
              }
            } else if (ditherMode === "halftone") {
              const cellSeed = column * 12.9898 + row * 78.233;
              const jitter = phase !== 0 ? Math.sin(phase + cellSeed) * 0.06 : 0;
              const darkness = clamp01(threshold - adjustedLuminance + 0.5 + jitter);
              const radius = (gridSize / 2) * darkness;
              if (radius > 0.3) {
                context!.fillStyle = onFillStyle;
                context!.beginPath();
                context!.arc(cellX + gridSize / 2, cellY + gridSize / 2, radius, 0, Math.PI * 2);
                context!.fill();
              }
            } else {
              // crosshatch — quantize darkness into 4 density bands.
              const cellSeed = column * 12.9898 + row * 78.233;
              const jitter = phase !== 0 ? Math.sin(phase * 0.6 + cellSeed) * 0.05 : 0;
              const darkness = clamp01(threshold - adjustedLuminance + 0.5 + jitter);
              const level = Math.min(3, Math.floor(darkness * 4));
              if (level > 0) {
                context!.strokeStyle = onFillStyle;
                context!.lineWidth = Math.max(0.5, gridSize * 0.14);
                context!.beginPath();
                context!.moveTo(cellX, cellY + gridSize);
                context!.lineTo(cellX + gridSize, cellY);
                if (level >= 2) {
                  context!.moveTo(cellX, cellY);
                  context!.lineTo(cellX + gridSize, cellY + gridSize);
                }
                context!.stroke();
                if (level >= 3) {
                  context!.fillStyle = onFillStyle;
                  context!.globalAlpha = 0.35;
                  context!.fillRect(cellX, cellY, gridSize, gridSize);
                  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, or an environment without
        // `IntersectionObserver` to gate the loop) never fire the "Remove"
        // hook below. Bailing here once the canvas is detached prevents the
        // loop from leaking forever across unrelated later tests.
        if (!canvas!.isConnected) return;
        const elapsedSeconds = (timeMs - startTimeMs) / 1000;
        drawFrame(elapsedSeconds * animationSpeed);
        animationFrameId = window.requestAnimationFrame(tick);
      }

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

      if (sampleContext) {
        image = new Image();
        image.crossOrigin = "anonymous";
        image.onload = () => {
          imageLoaded = true;
          resample();
          drawFrame(0);
          if (animated) startLoop();
        };
        image.onerror = () => {
          // Leave the canvas as a flat "paper" fill on load failure.
          drawFrame(0);
        };
        image.src = imageSource;
      }

      if (animated && typeof IntersectionObserver === "function") {
        intersectionObserver = new IntersectionObserver((entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) startLoop();
            else stopLoop();
          }
        });
        intersectionObserver.observe(wrapperElement);
      }

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

  return {
    div: [canvasElement],
    role: "img",
    ariaLabel: altText,
    style: {
      position: "relative",
      display: "block",
      width: `${outputWidth}px`,
      aspectRatio: `${outputWidth} / ${outputHeight}`,
      overflow: "hidden",
      borderRadius: themeSpacing(2),
      ...(props.style ?? {}),
    } as StyleObject,
  } as DomphyElement<"div">;
}

export { ditherShader };

← Back to Aceternity UI catalog