Design tools

Dither effect generator

Katsushika Hokusai, Under the Wave off Kanagawa, c. 1830. The Met.

Effect
Mask
88
Mask color
Color
Strength
100
Size
15
Spacing
0%
Usage
import { AsciiDitherEffect } from './ascii-dither-effect';

export function Hero() {
  return (
    <AsciiDitherEffect
      src='/your-image.jpg'
      alt=''
      tone='light'
      settings={{
    filter: "dither",
    mask: 88,
    palette: "original",
    inkTone: "shadows",
    strength: 100,
    size: 15,
    spacing: 0
      }}
      className='aspect-[4/3] w-full rounded-2xl'
    />
  );
}
ascii-dither-effect.tsx
'use client';

/**
 * ASCII and ordered-dither effect for an image, drawn on a canvas laid over it.
 * Made with https://layerwise.app/design-tools/image-to-ascii
 *
 * The image must be same-origin or served with CORS headers, because the
 * effect reads its pixels.
 */
import { useEffect, useRef, type CSSProperties } from 'react';

export type EffectFilter = 'ascii' | 'dither';
export type EffectPalette =
  'original' | 'meadow' | 'blue' | 'amber' | 'lavender' | 'mono';
/** The page behind the effect: a light page gets a white veil, a dark one black. */
export type EffectTone = 'light' | 'dark';

export type EffectSettings = {
  filter: EffectFilter;
  /** 0-100. A vertical veil so the effect reads as texture, not noise. */
  mask: number;
  palette: EffectPalette;
  /** ASCII only: which end of the tonal range becomes dense characters. */
  inkTone: 'shadows' | 'highlights';
  /** 0-100. ASCII contrast, or how few color levels the dither keeps. */
  strength: number;
  /** 0-100. ASCII font size, or the dither pixel block. */
  size: number;
  /** 0-100. ASCII grid step, or the scale of the Bayer pattern. */
  spacing: number;
};

export const RECOMMENDED_SETTINGS: Record<EffectFilter, EffectSettings> = {
  ascii: {
    filter: 'ascii',
    mask: 90,
    palette: 'original',
    inkTone: 'shadows',
    strength: 100,
    size: 37,
    spacing: 0
  },
  dither: {
    filter: 'dither',
    mask: 88,
    palette: 'original',
    inkTone: 'shadows',
    strength: 100,
    size: 15,
    spacing: 0
  }
};

/** Four gradient-map stops each, at 0, 0.4, 0.74, and 1. */
export const PALETTES: Record<
  Exclude<EffectPalette, 'original'>,
  readonly [string, string, string, string]
> = {
  meadow: ['#2B361F', '#677D45', '#AEBA78', '#E5E1BD'],
  blue: ['#192A4D', '#326AC5', '#A2C2F1', '#EDF3FC'],
  amber: ['#3C200F', '#A74C15', '#EF9A46', '#F7E7CA'],
  lavender: ['#292144', '#6552AB', '#B3A5E4', '#F0ECFB'],
  mono: ['#151515', '#575757', '#A6A6A6', '#ECECEC']
};

export const MONO_FONT =
  'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';

const PALETTE_STOPS = [0, 0.4, 0.74, 1];
// From empty to full; the two leading spaces leave the faintest cells blank.
const ASCII_CHARS = '  .,:;-=+xX08S#&@';
const BAYER_4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
const SAMPLE_LONG_SIDE = 480;
const SAMPLE_COUNT = 12_000;
const MAX_CANVAS_SIDE = 2048;
const MAX_PIXEL_RATIO = 1.6;
const CROSS_FADE_MS = 150;

type Rgb = [number, number, number];
export type Box = { width: number; height: number };

export type Sampler = {
  /** Color (0-255) of the picture under a point in the box. */
  at: (x: number, y: number) => Rgb;
  /** 8th and 92nd percentile of luminance, 0-1. */
  low: number;
  high: number;
};

export type PreparedSource = {
  box: Box;
  /** Device pixels per box pixel. */
  scale: number;
  sampler: Sampler;
  /** The cover-fit, recolored picture at output resolution. */
  picture: () => HTMLCanvasElement;
};

export type Glyph = {
  x: number;
  y: number;
  char: string;
  color: Rgb;
  alpha: number;
};

export type DitherBlocks = {
  /** Box pixels per block. */
  pixel: number;
  cols: number;
  rows: number;
  /** RGBA, one entry per block. */
  data: Uint8ClampedArray<ArrayBuffer>;
};

function range(value: number, from: number, to: number) {
  return from + ((to - from) * value) / 100;
}

function clamp(value: number, min = 0, max = 1) {
  return Math.min(max, Math.max(min, value));
}

function luminance([r, g, b]: Rgb) {
  return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}

function hexToRgb(hex: string): Rgb {
  const value = Number.parseInt(hex.slice(1), 16);
  return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}

/** A 256-entry RGB lookup from luminance into the palette's gradient. */
function paletteLookup(palette: Exclude<EffectPalette, 'original'>) {
  const stops = PALETTES[palette].map(hexToRgb);
  const lookup = new Uint8ClampedArray(256 * 3);
  for (let i = 0; i < 256; i++) {
    const t = i / 255;
    let segment = 0;
    while (segment < 2 && t > PALETTE_STOPS[segment + 1]!) segment++;
    const start = PALETTE_STOPS[segment]!;
    const local = (t - start) / (PALETTE_STOPS[segment + 1]! - start);
    for (let channel = 0; channel < 3; channel++) {
      const from = stops[segment]![channel]!;
      const to = stops[segment + 1]![channel]!;
      lookup[i * 3 + channel] = from + (to - from) * local;
    }
  }
  return lookup;
}

function recolor(data: Uint8ClampedArray, palette: EffectPalette): void {
  if (palette === 'original') return;
  const lookup = paletteLookup(palette);
  for (let i = 0; i < data.length; i += 4) {
    const index =
      Math.round(luminance([data[i]!, data[i + 1]!, data[i + 2]!]) * 255) * 3;
    data[i] = lookup[index]!;
    data[i + 1] = lookup[index + 1]!;
    data[i + 2] = lookup[index + 2]!;
  }
}

function createCanvas(width: number, height: number) {
  const canvas = document.createElement('canvas');
  canvas.width = Math.max(1, Math.round(width));
  canvas.height = Math.max(1, Math.round(height));
  return canvas;
}

function context2d(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
  if (!ctx) throw new Error('Canvas 2D is not available.');
  return ctx;
}

/** Draws the image over white, cropped to fill the canvas like `object-fit: cover`. */
function drawCover(
  canvas: HTMLCanvasElement,
  image: CanvasImageSource,
  imageWidth: number,
  imageHeight: number
) {
  const ctx = context2d(canvas);
  const fit = Math.max(canvas.width / imageWidth, canvas.height / imageHeight);
  const width = imageWidth * fit;
  const height = imageHeight * fit;
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(
    image,
    (canvas.width - width) / 2,
    (canvas.height - height) / 2,
    width,
    height
  );
  return ctx;
}

/** Reads RGBA pixels that cover `box` and measures their tonal range. */
export function createSampler(
  data: Uint8ClampedArray,
  width: number,
  height: number,
  box: Box
): Sampler {
  const pixel = (x: number, y: number): Rgb => {
    const index = (y * width + x) * 4;
    return [data[index]!, data[index + 1]!, data[index + 2]!];
  };
  const step = Math.max(1, Math.sqrt((width * height) / SAMPLE_COUNT));
  const levels: number[] = [];
  for (let y = step / 2; y < height; y += step) {
    for (let x = step / 2; x < width; x += step) {
      levels.push(luminance(pixel(Math.floor(x), Math.floor(y))));
    }
  }
  levels.sort((a, b) => a - b);
  const percentile = (p: number) =>
    levels[Math.floor(p * (levels.length - 1))] ?? 0;

  return {
    at: (x, y) =>
      pixel(
        clamp(Math.floor((x / box.width) * width), 0, width - 1),
        clamp(Math.floor((y / box.height) * height), 0, height - 1)
      ),
    low: percentile(0.08),
    high: percentile(0.92)
  };
}

/** Device pixels per box pixel for a canvas shown on this screen. */
export function screenScale(box: Box) {
  const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
  return Math.min(
    ratio,
    MAX_CANVAS_SIDE / box.width,
    MAX_CANVAS_SIDE / box.height
  );
}

export function prepareSource(
  image: CanvasImageSource,
  imageWidth: number,
  imageHeight: number,
  box: Box,
  palette: EffectPalette,
  scale: number
): PreparedSource {
  const sampleFit = SAMPLE_LONG_SIDE / Math.max(box.width, box.height);
  const sampleCanvas = createCanvas(
    box.width * sampleFit,
    box.height * sampleFit
  );
  const sampleImage = drawCover(
    sampleCanvas,
    image,
    imageWidth,
    imageHeight
  ).getImageData(0, 0, sampleCanvas.width, sampleCanvas.height);
  recolor(sampleImage.data, palette);

  let picture: HTMLCanvasElement | undefined;
  return {
    box,
    scale,
    sampler: createSampler(
      sampleImage.data,
      sampleCanvas.width,
      sampleCanvas.height,
      box
    ),
    picture: () => {
      if (picture) return picture;
      picture = createCanvas(box.width * scale, box.height * scale);
      const ctx = drawCover(picture, image, imageWidth, imageHeight);
      if (palette !== 'original') {
        const pixels = ctx.getImageData(0, 0, picture.width, picture.height);
        recolor(pixels.data, palette);
        ctx.putImageData(pixels, 0, 0);
      }
      return picture;
    }
  };
}

function smoothstep(x: number) {
  return x * x * (3 - 2 * x);
}

/** How much ink a cell gets, 0-1. */
export function asciiTone(
  level: number,
  sampler: Pick<Sampler, 'low' | 'high'>,
  settings: Pick<EffectSettings, 'inkTone' | 'strength'>
) {
  const s = settings.strength / 100;
  const darkInk = settings.inkTone === 'shadows';
  const { low, high } = sampler;
  const raw = darkInk ? 1 - level : level;
  const t =
    high - low < 0.025
      ? raw
      : (() => {
          const normalized = clamp(
            darkInk
              ? (high - level) / (high - low)
              : (level - low) / (high - low)
          );
          const mix = 0.72 + 0.14 * s;
          return raw * (1 - mix) + normalized * mix;
        })();
  const black = 0.22 * s;
  const white = 1 - 0.08 * s;
  const leveled = clamp((t - black) / (white - black));
  return leveled * (1 - s) + smoothstep(leveled) * s;
}

function rgbToHsv([r, g, b]: Rgb): Rgb {
  const max = Math.max(r, g, b) / 255;
  const min = Math.min(r, g, b) / 255;
  const delta = max - min;
  let hue = 0;
  if (delta > 0) {
    if (max === r / 255) hue = ((g - b) / 255 / delta) % 6;
    else if (max === g / 255) hue = (b - r) / 255 / delta + 2;
    else hue = (r - g) / 255 / delta + 4;
  }
  return [(hue * 60 + 360) % 360, max === 0 ? 0 : delta / max, max];
}

function hsvToRgb([hue, saturation, value]: Rgb): Rgb {
  const chroma = value * saturation;
  const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
  const m = value - chroma;
  const sector = Math.floor(hue / 60) % 6;
  const [r, g, b] = (
    [
      [chroma, x, 0],
      [x, chroma, 0],
      [0, chroma, x],
      [0, x, chroma],
      [x, 0, chroma],
      [chroma, 0, x]
    ] as const
  )[sector]!;
  return [
    Math.round((r + m) * 255),
    Math.round((g + m) * 255),
    Math.round((b + m) * 255)
  ];
}

export function asciiFontSize(box: Box, size: number) {
  return Math.round(range(size, 7, box.width < 720 ? 22 : 24));
}

/** Lays out one character per grid cell, picked by the cell's brightness. */
export function layoutAscii(
  sampler: Sampler,
  box: Box,
  settings: EffectSettings,
  measure: (text: string) => { width: number; height: number }
): Glyph[] {
  const s = settings.strength / 100;
  const font = asciiFontSize(box, settings.size);
  const widest = Math.max(
    ...[...new Set(ASCII_CHARS.trim())].map((char) => measure(char).width)
  );
  const stepX = Math.max(range(settings.spacing, 8, 24), widest * 1.08);
  const stepY = Math.max(
    range(settings.spacing, 9, 26),
    measure('@').height * 1.12,
    font * 1.04
  );
  const glyphs: Glyph[] = [];
  for (let y = stepY / 2; y < box.height; y += stepY) {
    for (let x = stepX / 2; x < box.width; x += stepX) {
      const color = sampler.at(x, y);
      const tone = asciiTone(luminance(color), sampler, settings);
      if (tone <= 0.02) continue;
      const char = ASCII_CHARS[Math.round(tone * 16)]!;
      if (char === ' ') continue;
      const [hue, saturation, value] = rgbToHsv(color);
      glyphs.push({
        x,
        y,
        char,
        color: hsvToRgb([
          hue,
          clamp(saturation * (1.16 + 0.2 * s)),
          value + (1 - value) * (0.24 + 0.18 * s + 0.24 * tone)
        ]),
        alpha: clamp((0.12 + 0.7 * tone) * (0.72 + 0.28 * s), 0.06, 0.94)
      });
    }
  }
  return glyphs;
}

/** Quantizes one channel against a Bayer threshold. */
export function ditherChannel(
  value: number,
  levels: number,
  threshold: number,
  blend: number
) {
  const scaled = (value / 255) * (levels - 1);
  const base = Math.floor(scaled);
  const quantized = base + (scaled - base > threshold ? 1 : 0);
  return value + ((quantized / (levels - 1)) * 255 - value) * blend;
}

export function ditherPixelSize(box: Box, size: number) {
  return Math.max(2, Math.round(range(size, 2, box.width < 720 ? 7 : 9)));
}

/** Ordered (Bayer 4x4) dither of the picture, one color per pixel block. */
export function layoutDither(
  sampler: Sampler,
  box: Box,
  settings: EffectSettings
): DitherBlocks {
  const s = settings.strength / 100;
  const pixel = ditherPixelSize(box, settings.size);
  const patternScale = Math.max(1, Math.round(range(settings.spacing, 1, 4)));
  const levels = Math.max(2, Math.round(range(settings.strength, 8, 2)));
  const blend = range(settings.strength, 0.32, 1);
  const saturate = 1.06 + 0.18 * s;
  const cols = Math.ceil(box.width / pixel);
  const rows = Math.ceil(box.height / pixel);
  const data = new Uint8ClampedArray(cols * rows * 4);

  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      const color = sampler.at(
        Math.min(box.width - 1, (col + 0.5) * pixel),
        Math.min(box.height - 1, (row + 0.5) * pixel)
      );
      const luma = luminance(color) * 255;
      const matrix =
        BAYER_4[
          (Math.floor(row / patternScale) % 4) * 4 +
            (Math.floor(col / patternScale) % 4)
        ]!;
      const threshold = (matrix + 0.5) / 16;
      const index = (row * cols + col) * 4;
      for (let channel = 0; channel < 3; channel++) {
        const pushed = clamp(
          luma + (color[channel]! - luma) * saturate,
          0,
          255
        );
        data[index + channel] = ditherChannel(pushed, levels, threshold, blend);
      }
      data[index + 3] = 255;
    }
  }
  return { pixel, cols, rows, data };
}

/** Veil gradient stops: offset 0-1 and opacity 0-1 of white or black. */
export function maskStops(mask: number, tone: EffectTone) {
  const bases = tone === 'light' ? [54, 20, 38] : [44, 20, 50];
  return [0, 0.42, 1].map((offset, i) => {
    const base = bases[i]!;
    const opacity =
      mask <= 50
        ? base * (mask / 50) ** 1.08
        : base + (100 - base) * ((mask - 50) / 50) ** 1.08;
    return { offset, opacity: opacity / 100 };
  });
}

function drawMask(
  ctx: CanvasRenderingContext2D,
  box: Box,
  mask: number,
  tone: EffectTone
) {
  const gradient = ctx.createLinearGradient(0, 0, 0, box.height);
  const channel = tone === 'light' ? 255 : 0;
  for (const stop of maskStops(mask, tone)) {
    gradient.addColorStop(
      stop.offset,
      `rgb(${channel} ${channel} ${channel} / ${stop.opacity})`
    );
  }
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, box.width, box.height);
}

/** Sizes `canvas` to the source and draws one frame of the effect. */
export function paintEffect(
  canvas: HTMLCanvasElement,
  source: PreparedSource,
  settings: EffectSettings,
  tone: EffectTone
) {
  const { box, scale, sampler } = source;
  canvas.width = Math.round(box.width * scale);
  canvas.height = Math.round(box.height * scale);
  const ctx = context2d(canvas);
  ctx.setTransform(scale, 0, 0, scale, 0, 0);

  if (settings.filter === 'ascii') {
    ctx.drawImage(source.picture(), 0, 0, box.width, box.height);
    drawMask(ctx, box, settings.mask, tone);
    ctx.font = `${asciiFontSize(box, settings.size)}px ${MONO_FONT}`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    const glyphs = layoutAscii(sampler, box, settings, (text) => {
      const metrics = ctx.measureText(text);
      return {
        width: metrics.width,
        height:
          metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent
      };
    });
    for (const glyph of glyphs) {
      const [r, g, b] = glyph.color;
      ctx.fillStyle = `rgb(${r} ${g} ${b} / ${glyph.alpha})`;
      ctx.fillText(glyph.char, glyph.x, glyph.y);
    }
    return;
  }

  const blocks = layoutDither(sampler, box, settings);
  const blockCanvas = createCanvas(blocks.cols, blocks.rows);
  context2d(blockCanvas).putImageData(
    new ImageData(blocks.data, blocks.cols, blocks.rows),
    0,
    0
  );
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(
    blockCanvas,
    0,
    0,
    blocks.cols * blocks.pixel,
    blocks.rows * blocks.pixel
  );
  drawMask(ctx, box, settings.mask, tone);
}

/**
 * Shows `src` with the effect drawn over it. Size the effect with `className`
 * or `style`; the image fills it like `object-fit: cover`.
 */
export function AsciiDitherEffect({
  src,
  alt = '',
  settings,
  tone = 'light',
  className,
  style
}: {
  src: string;
  alt?: string;
  settings: EffectSettings;
  tone?: EffectTone;
  className?: string;
  style?: CSSProperties;
}) {
  const imageRef = useRef<HTMLImageElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  // The cover-fit copy is the slow part, so keep it while only the look changes.
  const preparedRef = useRef<{ key: string; source: PreparedSource } | null>(
    null
  );
  const lookRef = useRef('');
  const { filter, mask, palette, inkTone, strength, size, spacing } = settings;

  useEffect(() => {
    const image = imageRef.current;
    const canvas = canvasRef.current;
    if (!image || !canvas) return;
    const current = { filter, mask, palette, inkTone, strength, size, spacing };
    let frame = 0;
    let fade = 0;

    const render = () => {
      frame = 0;
      cancelAnimationFrame(fade);
      const box = { width: canvas.clientWidth, height: canvas.clientHeight };
      if (!image.complete || !image.naturalWidth || !box.width || !box.height)
        return;
      const scale = screenScale(box);
      const key = `${image.currentSrc}|${box.width}x${box.height}@${scale}|${palette}`;
      if (preparedRef.current?.key !== key) {
        preparedRef.current = {
          key,
          source: prepareSource(
            image,
            image.naturalWidth,
            image.naturalHeight,
            box,
            palette,
            scale
          )
        };
      }
      const source = preparedRef.current.source;
      const look = `${filter}|${palette}`;
      const crossFade = lookRef.current !== '' && lookRef.current !== look;
      lookRef.current = look;
      if (!crossFade) {
        paintEffect(canvas, source, current, tone);
        return;
      }

      // Switching filter or palette fades from the last frame to the new one.
      const previous = createCanvas(canvas.width, canvas.height);
      context2d(previous).drawImage(canvas, 0, 0);
      const next = createCanvas(canvas.width, canvas.height);
      paintEffect(next, source, current, tone);
      canvas.width = next.width;
      canvas.height = next.height;
      const ctx = context2d(canvas);
      const start = performance.now();
      const step = (now: number) => {
        const progress = Math.min(1, (now - start) / CROSS_FADE_MS);
        ctx.globalAlpha = 1;
        ctx.drawImage(previous, 0, 0, canvas.width, canvas.height);
        ctx.globalAlpha = progress;
        ctx.drawImage(next, 0, 0);
        ctx.globalAlpha = 1;
        if (progress < 1) fade = requestAnimationFrame(step);
      };
      fade = requestAnimationFrame(step);
    };
    const schedule = () => {
      if (!frame) frame = requestAnimationFrame(render);
    };

    schedule();
    image.addEventListener('load', schedule);
    const observer = new ResizeObserver(schedule);
    observer.observe(canvas);
    return () => {
      cancelAnimationFrame(frame);
      cancelAnimationFrame(fade);
      image.removeEventListener('load', schedule);
      observer.disconnect();
    };
  }, [filter, mask, palette, inkTone, strength, size, spacing, tone, src]);

  return (
    <div
      className={className}
      style={{ position: 'relative', overflow: 'hidden', ...style }}
    >
      <img
        ref={imageRef}
        src={src}
        alt={alt}
        crossOrigin='anonymous'
        style={{
          display: 'block',
          width: '100%',
          height: '100%',
          objectFit: 'cover'
        }}
      />
      <canvas
        ref={canvasRef}
        aria-hidden='true'
        style={{
          position: 'absolute',
          inset: 0,
          width: '100%',
          height: '100%',
          pointerEvents: 'none'
        }}
      />
    </div>
  );
}

Try next

Image to ASCII art converter

Redraw the same image in characters, from a dot to @.

Open tool →