Image to ASCII art converter
Katsushika Hokusai, Under the Wave off Kanagawa, c. 1830. The Met.
Effect
Mask90
Mask color
Color
Sample tones
Strength100
Size37
Spacing0%
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.
