Design tools

Image to ASCII art converter

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

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

export function Hero() {
  return (
    <AsciiDitherEffect
      src='/your-image.jpg'
      alt=''
      tone='light'
      settings={{
    filter: "ascii",
    mask: 90,
    palette: "original",
    inkTone: "shadows",
    strength: 100,
    size: 37,
    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

Dither effect generator

Retro pixel blocks with a Bayer pattern, from the same image.

Open tool →