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

CompanyStudy
DateJul 20, 2026
TagsCanvas, Type, Halftone
Sourcetext.ksawerykomputery.pl

MIT → free to copy