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.
How do you tune the portrait without losing facial detail?
Begin with a fixed source image and turn jitter off. Compare the eyes, nose, mouth, and silhouette with the original before adding texture. If the face disappears even in the clean rendering, the problem is sampling or tone mapping. Randomness can make a weak result look interesting, but it will not recover detail the sampling step removed.
Change one parameter at a time. Smaller scanline spacing preserves more vertical detail, while shorter bars preserve more horizontal transitions. A high threshold removes faint background marks but can also remove cheeks and highlights. Keep several saved presets so you can compare the trade-offs directly rather than relying on memory between slider movements.
Why does the output change every time?
The sample uses random values for bar lengths and gaps. For a reproducible export, replace uncontrolled randomness with a seeded generator or store the generated bar geometry. That makes a bug report meaningful: the same source and settings can produce the same image while you inspect the algorithm.
A deterministic result also helps an AI agent iterate. Ask it to change sampling or tone mapping while holding the seed constant. Otherwise every comparison mixes the intended change with a new random pattern, and a prettier frame may be mistaken for an algorithm improvement.
What can go wrong when the image comes from another site?
Canvas pixel access follows the browser's origin rules. Setting the image's cross-origin property is only part of the setup; the image server must also permit the request. An image can display successfully and still prevent pixel reads. MDN's pixel manipulation guide explains the origin-clean requirement.
Test with a same-origin image first. If that works and a remote image fails, inspect the image request and response headers instead of changing the halftone math. Show a readable error in the UI and preserve the selected settings so the user can retry with an uploaded local file.
For uploaded images, bound the processing resolution. A phone photograph may contain far more pixels than a portrait preview needs. Resize into a working canvas before sampling, and keep the original available if you later need a larger export. The preview and export can share the algorithm while using different resolutions.
How should the finished effect behave as a product feature?
Put controls and status text in ordinary HTML so people can use them with a keyboard. Label the original image and the processed preview clearly. If the portrait conveys information, provide a text alternative; a bitmap effect does not automatically preserve accessible meaning.
Treat export as a separate acceptance check. Download the resulting file, open it, and verify its dimensions, background, and crop. A canvas that looks correct when scaled by CSS can export a different size than the user expects. Name the format and resolution near the export action.
The example here establishes an implementation approach rather than a device benchmark. Measure image loading, processing, and export on the devices your audience uses before promising a latency target. That boundary is especially useful when a coding agent produces a convincing demo but has not tested large uploads or mobile memory pressure.
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.
