Words as pixels
I saw a page that turned your camera into a wall of words, and I could not stop looking. Every word is one pixel of the picture, so you read a sentence and see a face at the same time.
It is a text halftone. A tiny copy of the image sits behind the wall, and each word takes the color of the pixel underneath it. The words never move, only their background colors do, so the picture forms out of real readable text.
Below you can point at it to warp the image, switch to your own camera or photo, and play with the density, the contrast, and the colors. The words spell out how the whole thing works.
Playground
The words the picture is made of
Code
<!-- Words as pixels — a picture drawn entirely out of real words. A dense wall of
text is laid out as live DOM (one <span> per word); a tiny hidden copy of a
source (image / video / camera) sits behind it, and each word takes the colour
of the pixel underneath it. The words never move, only their background colours
do, so a readable sentence and a recognizable picture share the same space.
Rebuilt framework-free from text.ksawerykomputery.pl (originally p5.js), with
the per-frame layout thrash removed: each word's cell is measured ONCE (and on
resize), so the render loop only reads a tiny pixel buffer and writes
background-color — no getBoundingClientRect() in the hot path.
Drop this whole file into a browser. Change SRC to your own image, or call
useCamera() from the console. Everything is inline; no build step. -->
<!DOCTYPE html>
<meta charset="utf-8" />
<title>Words as pixels</title>
<style>
html, body { margin: 0; height: 100%; background: #fcfcfc; }
#wall {
position: fixed; inset: 0; overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
line-height: 1.05; letter-spacing: -0.02em;
text-align: justify; user-select: none;
}
#wall p { margin: 0; text-align-last: justify; }
#wall span {
display: inline-block; padding: 0 0.18em; margin: 0 1px 1px 0;
border-radius: 1px; background: #888; color: #141414;
vertical-align: middle; transition: background-color 60ms linear;
}
</style>
<div id="wall"></div>
<script>
// ── config ─────────────────────────────────────────────────────────────────
const SRC = "portrait.jpg"; // your image (same-origin or CORS-clean)
const ROWS = 88; // source buffer height in cells (density)
const TEXT = // the words the picture is made of
"Every word you see is one pixel of a picture. The words spell out how this " +
"works, and their colors quietly redraw whatever is behind them.";
const GRADE = { brightness: 1.05, saturation: 1.2, contrast: 1.4 };
const wall = document.getElementById("wall");
const src = document.createElement("canvas");
const sctx = src.getContext("2d", { willReadFrequently: true });
sctx.imageSmoothingEnabled = false;
const words = TEXT.replace(/[.,()]/g, "").trim().split(/\s+/);
let cols = 1, rows = ROWS, cells = []; // cells: { el, ind }
let source = null; // { kind, el }
// ── build the wall + measure each word's cell ONCE ──────────────────────────
function rebuild() {
const w = wall.clientWidth, h = wall.clientHeight;
rows = ROWS;
cols = Math.max(1, Math.round(rows * (w / h)));
src.width = cols; src.height = rows;
wall.style.fontSize = (h / rows) * 0.82 + "px";
const target = Math.ceil(cols * rows * 1.35);
const p = document.createElement("p");
const spans = [];
for (let i = 0; i < target; i++) {
const s = document.createElement("span");
s.textContent = words[i % words.length];
p.appendChild(s); spans.push(s);
}
wall.replaceChildren(p);
// one batched read pass → one reflow, not one per span
cells = [];
for (const el of spans) {
const cx = el.offsetLeft + el.offsetWidth / 2;
const cy = el.offsetTop + el.offsetHeight / 2;
if (cy > h) continue;
const sx = Math.min(cols - 1, Math.floor((cx / w) * cols));
const sy = Math.min(rows - 1, Math.floor((cy / h) * rows));
cells.push({ el, ind: (sy * cols + sx) * 4 });
}
}
// ── per frame: draw source → grade → paint each word's background ───────────
function frame() {
sctx.fillStyle = "#000";
sctx.fillRect(0, 0, cols, rows);
if (source) {
const el = source.el;
const sw = source.kind === "video" ? el.videoWidth : el.naturalWidth;
const sh = source.kind === "video" ? el.videoHeight : el.naturalHeight;
if (sw && sh) {
const scale = Math.max(cols / sw, rows / sh);
const dw = sw * scale, dh = sh * scale;
try { sctx.drawImage(el, (cols - dw) / 2, (rows - dh) / 2, dw, dh); } catch {}
}
}
const data = sctx.getImageData(0, 0, cols, rows).data;
// grade the tiny buffer (cheap at cols×rows)
for (let i = 0; i < data.length; i += 4) {
let r = data[i], g = data[i + 1], b = data[i + 2];
r *= GRADE.brightness; g *= GRADE.brightness; b *= GRADE.brightness;
const l = 0.299 * r + 0.587 * g + 0.114 * b;
r = l + (r - l) * GRADE.saturation;
g = l + (g - l) * GRADE.saturation;
b = l + (b - l) * GRADE.saturation;
data[i] = clamp(128 + (r - 128) * GRADE.contrast);
data[i + 1] = clamp(128 + (g - 128) * GRADE.contrast);
data[i + 2] = clamp(128 + (b - 128) * GRADE.contrast);
}
for (const c of cells) {
const i = c.ind;
c.el.style.backgroundColor = `rgb(${data[i]},${data[i + 1]},${data[i + 2]})`;
}
requestAnimationFrame(frame);
}
const clamp = (v) => (v < 0 ? 0 : v > 255 ? 255 : v);
// ── sources ─────────────────────────────────────────────────────────────────
function useImage(url) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => (source = { kind: "image", el: img });
img.src = url;
}
async function useCamera() { // call from the console to go live
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const v = document.createElement("video");
v.muted = true; v.playsInline = true; v.srcObject = stream;
await v.play();
source = { kind: "video", el: v };
}
window.useCamera = useCamera;
addEventListener("resize", rebuild);
rebuild();
useImage(SRC);
requestAnimationFrame(frame);
</script>
Credits
MIT → free to copy