ASCII Halftone Canvas
Turns any canvas drawing into live ASCII art, sampled one pixel per glyph.
- Category
- Assets
- Added
- 2026-09-20
- Deps
- none
- Updated
- 2026-09-20
Props
"use client";
import { useEffect, useRef } from "react";
const RAMP = " .:-=+*#%@";
export function AsciiHalftoneCanvas({
draw,
cols = 54,
invert = false,
animate = true,
speed = 0.35,
}: {
/**
* Paints the source frame. Receives a 2D context already sized to the
* CHARACTER grid (cols x lines), plus `t`, a seconds counter for animation.
* Taking a draw callback instead of an image src is deliberate — see the
* tainting note below.
*/
draw: (ctx: CanvasRenderingContext2D, w: number, h: number, t: number) => void;
cols?: number;
invert?: boolean;
/** Advance the seconds counter passed to the draw callback. Off freezes it. */
animate?: boolean;
/** Radians per second the key light travels. Ambient is well under 1. */
speed?: number;
}) {
const preRef = useRef<HTMLPreElement>(null);
const frame = useRef<number | null>(null);
const opts = useRef({ cols, invert, animate, draw, speed });
useEffect(() => {
opts.current = { cols, invert, animate, draw, speed };
}, [cols, invert, animate, draw, speed]);
useEffect(() => {
const canvas = document.createElement("canvas");
// willReadFrequently tells the browser to keep this canvas on the CPU.
// Without it, a GPU-backed canvas has to be read back every frame, which
// is far slower for a getImageData-per-frame workload like this one.
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
let t = 0;
let last = 0;
function render() {
const { cols: rawCols, invert: inv, animate: anim, draw: paint, speed: sp } = opts.current;
// BOUNDED BEFORE IT SIZES A CANVAS.
//
// cols is a CHARACTER count, but nothing in the type says so — it is
// just a number. A copier who reads it as pixels and passes 5000 gets
// a 5000x2100 canvas reallocated every frame: roughly 42MB per
// getImageData call, sixty times a second, wrapped around a
// ten-million-iteration string concatenation inside the rAF callback.
// The frame is gone long before anything paints. The ceiling here is
// what makes that unreachable.
const c = Math.min(200, Math.max(4, Math.round(Number(rawCols) || 54)));
// The offscreen canvas is sized to the CHARACTER grid, not the display
// size: one pixel read per glyph, so cost tracks the number of
// characters shown rather than the resolution.
const lines = Math.max(4, Math.round(c * 0.42)); // glyph aspect ratio
canvas.width = c;
canvas.height = lines;
//
// TIME-BASED, NOT PER-FRAME.
//
// Advancing a fixed amount every frame ties the animation to the refresh
// rate: the same code runs at DOUBLE speed on a 120Hz display and half
// speed on a throttled tab, and you never see it on the machine you
// built it on. The delta is clamped so a backgrounded tab does not
// return and jump the light a quarter of the way round its orbit.
//
const now = performance.now();
const dt = last ? Math.min(100, now - last) : 16.7;
last = now;
if (anim) t += (dt / 1000) * sp;
paint(ctx, c, lines, t);
const data = ctx.getImageData(0, 0, c, lines).data;
let out = "";
for (let y = 0; y < lines; y++) {
for (let x = 0; x < c; x++) {
const i = (y * c + x) * 4;
// Rec. 601 luma — perceptual, unlike a flat (r+g+b)/3 average.
const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
const v = inv ? 1 - lum : lum;
out += RAMP[Math.min(RAMP.length - 1, Math.floor(v * RAMP.length))];
}
out += "\n";
}
// Written straight to the DOM node. Holding this in React state would
// mean a full component render 60 times a second to update a string.
if (preRef.current) preRef.current.textContent = out;
frame.current = requestAnimationFrame(render);
}
frame.current = requestAnimationFrame(render);
return () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
};
}, []);
return (
<pre
ref={preRef}
className="select-none font-mono leading-[0.78]"
style={{ fontSize: 8 }}
/>
);
}
About this asset
Three decisions separate a usable ASCII renderer from a demo that falls over. The first is the API: it takes a draw callback rather than an image URL, because `getImageData` throws a SecurityError on any canvas that has had a cross-origin image drawn into it — the failure every `src`-based converter hits the moment someone points it at a CDN. The second is the sampling grid. The offscreen canvas is sized to the character grid, not the display size, so there is exactly one pixel read per glyph and the cost tracks how many characters are visible rather than the resolution they are shown at; a full-resolution read plus averaging is orders of magnitude more work for identical output. The third is where the result goes: the assembled string is written directly to a `pre` node's `textContent`, because holding it in state means a full component render sixty times a second to update a string. Two smaller details matter — luma uses the Rec. 601 weights rather than a flat channel average, which misjudges saturated colors badly, and `willReadFrequently` keeps the canvas on the CPU where a read-per-frame workload belongs.
Curator’s note
Built for viberdy 2.0. The draw-callback API exists because of canvas tainting, not because it is tidier — an src prop would break on the first CDN image.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Halftone Depth Field
backgroundsA rotated print screen that swells into a pool of light under the pointer, with the gamma actually done right.
Ordered Dither Plate
backgroundsA gradient quantised to a few tones by an 8x8 Bayer matrix — offset then quantised, dithered in linear light, one cell per device pixel.
Pointer Warp Dot Field
backgroundsA canvas dot grid that bulges and brightens away from the cursor.