Tibo, who leads Codex and ChatGPT at OpenAI, posted a portrait rendered entirely as broken horizontal ink lines on August 17, 2026 — captioned simply "courtesy of Codex." It's a striking effect: from a distance it reads as a photograph, up close it's just ragged black bars of varying length, like a fax machine trying to print a face. Designer Jacob Miller (@pwnies, the developer behind diffui.ai) replied asking whether anyone had "figured out a good way to do this paper ink bleed effect with shaders / CSS" — then posted his own version of the effect applied to a colleague's photo a few hours later.
That's a genuinely fun creative-coding problem, and a good one to actually solve rather than just admire. Here's a working implementation.
TL;DR: two ways to build it
| Approach | Best for | Effort |
|---|---|---|
| Canvas line-halftone script | Accurate reproduction — bar length/thickness actually tracks image luminance | ~40 lines of JavaScript, runs once per image |
| CSS/SVG filter approximation | Fast, no pixel reading, works as a live filter on any element | A few lines of SVG <filter>, applied via filter: url(#id) |
| Does it need a GPU shader? | Only for real-time video/streams | Optional — see the WebGL note at the end |
| Can an AI coding agent build this from a prompt? | Yes — it's exactly the kind of visual, self-checkable task Codex, Claude Code, or Cursor handle well | One well-scoped prompt, described below |
What the effect actually is
Look closely at the viral image: it isn't dots (a classic halftone), it's horizontal bars. Each scanline of the source image gets converted into a row of black bars — longer and thicker where the source pixels are darker, shorter and thinner where they're lighter — and the bar edges are jittered randomly so they look torn rather than crisp. That jitter is what makes it read as "ink bleed" instead of a clean technical scan.
That's three ingredients:
- Row sampling — walk the source image row by row (or every N pixels for a coarser, chunkier look).
- Luminance-to-length mapping — convert each sampled pixel's brightness into a bar length.
- Edge jitter — randomize bar length and spacing slightly so edges look bled rather than measured.
The accurate version: canvas line-halftone
This runs in any browser, reads the source image via getImageData, and draws the result to a second canvas:
async function inkBleedPortrait(imageUrl, canvas, options = {}) {
const {
rowHeight = 4, // vertical spacing between scanlines
maxBarLength = 26, // longest possible bar, in pixels
jitter = 0.5, // 0 = perfectly clean, 1 = maximally torn
threshold = 0.12, // ignore near-white pixels entirely
} = options;
const img = await new Promise((resolve, reject) => {
const el = new Image();
el.crossOrigin = 'anonymous';
el.onload = () => resolve(el);
el.onerror = reject;
el.src = imageUrl;
});
// Read the source into an offscreen canvas so we can sample pixels.
const source = document.createElement('canvas');
source.width = img.width;
source.height = img.height;
const sctx = source.getContext('2d');
sctx.drawImage(img, 0, 0);
const { data } = sctx.getImageData(0, 0, img.width, img.height);
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#111111';
for (let y = 0; y < img.height; y += rowHeight) {
let x = 0;
while (x < img.width) {
const i = (y * img.width + x) * 4;
const luminance = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
const darkness = 1 - luminance;
if (darkness > threshold) {
const jitterFactor = 1 - jitter + Math.random() * jitter;
const barLength = Math.max(2, darkness * maxBarLength * jitterFactor);
const barHeight = rowHeight * (0.35 + darkness * 0.65);
ctx.fillRect(x, y + (rowHeight - barHeight) / 2, barLength, barHeight);
x += barLength + Math.random() * 3; // gap before the next segment
} else {
x += 4; // skip light areas faster — no bar drawn
}
}
}
}
// Usage:
// const canvas = document.querySelector('#output');
// inkBleedPortrait('/portrait.jpg', canvas, { rowHeight: 3, maxBarLength: 20 });
Tune it from there: lower rowHeight (down to 2px) for finer detail closer to the viral example; raise jitter toward 1 for a rougher, more distressed look; raise threshold if midtones are producing too much noise in flat background areas.
The fast approximation: SVG filter, no pixel reading
If you don't need the bar lengths to track the actual image content — just the torn, ink-bled texture applied over an existing image or shape — an SVG filter gets most of the visual character for free, and it works as a live CSS filter on any element:
<svg style="position: absolute; width: 0; height: 0;">
<filter id="ink-bleed">
<feTurbulence type="fractalNoise" baseFrequency="0.01 0.9" numOctaves="2" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="8" />
<feColorMatrix type="matrix" values="0 0 0 0 0.07 0 0 0 0 0.07 0 0 0 0 0.07 0 0 0 1 0" />
</filter>
</svg>
<img src="/portrait.jpg" style="filter: url(#ink-bleed);" />
The baseFrequency="0.01 0.9" is doing the work here — a low horizontal frequency and a high vertical one stretches the turbulence noise into the same horizontal-streak character as the canvas version, without ever reading a single pixel value. It's a good starting point; for a closer match to a genuine line-halftone, layer it over horizontal repeating-linear-gradient stripes with mix-blend-mode: multiply. This general SVG-filter technique for ink-style distortion is documented in more depth in Andy Jakubowski's ink bleed tutorial and Carmen Ansio's browser halftone writeup, both worth reading if you want to go deeper than this guide.
Getting an AI coding agent to build it for you
Tibo's post credits Codex directly, and that's not surprising — this is exactly the kind of task coding agents are good at: a self-contained visual algorithm you can verify by looking at the output, with no ambiguity about whether it "worked." A prompt that gets you most of the way there in one pass:
Write a JavaScript function that loads an image onto a canvas, reads its pixel data, and re-renders it as horizontal black bars: for each scanline, sample luminance across the row and draw bar segments whose length and thickness scale with darkness. Add randomized jitter to bar length and spacing so edges look torn/ink-bled rather than clean. Skip near-white pixels entirely. Expose rowHeight, maxBarLength, jitter, and threshold as tunable parameters.
That's close to the exact prompt structure behind the "three ingredients" breakdown earlier in this post — row sampling, luminance mapping, edge jitter — because naming the algorithm's actual steps, not just describing the desired look, is what keeps a coding agent from guessing at implementation details you'd have to fix by hand anyway. explainx.ai's loop engineering guide covers this pattern in more depth: give the agent a tight, checkable spec and let it iterate against the visual result rather than trying to describe the destination and hoping it fills in the mechanism correctly.
When you'd actually reach for a GPU shader instead
Everything above runs once per static image, which is all the viral post needed. If you want this effect live on video, a webcam feed, or applied per-frame in a generative art piece, move the luminance-to-bar logic into a GLSL fragment shader: sample the source texture at each scanline's y-coordinate, compute darkness the same way, and use a step() function against a per-row noise value (from a hash or simplex noise function) to decide bar presence — the same three ingredients, just running per-pixel on the GPU instead of per-row on the CPU. That's overkill for a single portrait, but it's the natural next step if this effect becomes part of a live tool rather than a one-off render.
Related reading
- Loop Engineering for Coding Agents: The Complete Guide
- Anatomy Atelier: A Solo Dev Built 3D Anatomy With Codex and TripoAI
- Figma Config 2026 Recap: Motion, Code, and Shaders in Design Tools
- AI Aesthetic Design Patterns
- Top AI Prompts for Design
- Best AI Coding Subscription Under $20 (ChatGPT Plus vs. Claude Pro)
Further reading on the underlying technique: Ink bleed effect with SVG filters — Andy Jakubowski · Halftone effect in the browser — Carmen Ansio
Code samples in this post are an original implementation inspired by the publicly posted effect, not a reproduction of Codex's or diffui's actual source — both remain unpublished as of August 17, 2026.
