explainx.ai0k
TrendingAI News TodayPathwaysSkills
Pricing
explainx.ai

Upskill in AI — 16 free pathways, live workshops & bootcamps, and 50+ courses from practitioners. Plus the skills, tools, and MCP servers to practice on.

follow us

follow on google

Add explainx.ai as a preferred source

corporate training

support@explainx.ai

get started

Find your pathTake Free Evaluation

community

Join the community

learn

mind: share how you thinkpathways — start freeworkshopsbootcampscoursescompare Explainxcertificationsmock testsexplainx universitycorporate traininglearn skills & mcp

discover

skillsmcp serversexplainx mcptoolsmdx readeragentsllmsdesignsdictionarypeopleagi trackerfelony benchranks

company

aboutvisionmissionteaminstructorsteach on explainxpartnershipscommunityhackathonscareers

content

daily AI newsstate of AI — live resultsblogreleasespromptsgeneratorsresource libraryfor LLMsexplainx.ai kids

solutions

all solutionsdeveloper upskillingmarketing upskillingproduct manager upskillingleadership upskilling

newsletter · weekly

Get AI news, tools, and insights in your inbox.

supportcontactprivacytermsdata rightshow we create contentsubmission guidelines

© 2026 AISOLO Technologies Pvt Ltd

explainx.ai

On this page

  • TL;DR: two ways to build it
  • What the effect actually is
  • The accurate version: canvas line-halftone
  • The fast approximation: SVG filter, no pixel reading
  • Getting an AI coding agent to build it for you
  • When you'd actually reach for a GPU shader instead
  • How do you tune the portrait without losing facial detail?
  • What can go wrong when the image comes from another site?
  • How should the finished effect behave as a product feature?
  • Related reading
← Back to blog

explainx / blog

How to Build the Viral "Ink Bleed" Portrait Effect (Canvas + CSS)

Creative Coding, CSS, Canvas, Codex, Generative Art

Tibo posted a scanline "ink bleed" portrait made with Codex and it went viral. Here's how to build the same line-halftone effect yourself with a canvas script, plus a lighter CSS/SVG-filter version.

Aug 17, 2026·8 min read·Yash Thakker
add explainx.ai
go deep
How to Build the Viral "Ink Bleed" Portrait Effect (Canvas + CSS)

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.

Weekly digest3.5k readers

Catch up on AI

Curated AI updates on agents, skills, and MCP — delivered to your inbox. Unsubscribe anytime.

TL;DR: two ways to build it

table · 3 cols
ApproachBest forEffort
Canvas line-halftone scriptAccurate reproduction — bar length/thickness actually tracks image luminance~40 lines of JavaScript, runs once per image
CSS/SVG filter approximationFast, no pixel reading, works as a live filter on any elementA few lines of SVG <filter>, applied via filter: url(#id)
Does it need a GPU shader?Only for real-time video/streamsOptional — 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 wellOne 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:

  1. Row sampling — walk the source image row by row (or every N pixels for a coarser, chunkier look).
  2. Luminance-to-length mapping — convert each sampled pixel's brightness into a bar length.
  3. 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:

javascript
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:

html
<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.

Spotted something out of date? Let us know.
Yash Thakker

Written by

Yash Thakker

Yash is an AI expert with over 300K learners. Join his workshops →

View Yash Thakker in People in AI →

Related posts

Oct 4, 2026

Tibo Says "6.1 Coming Soon": Is It GPT-6.1 Astra, Sol Ultrafast, or Luna?

On October 4, 2026, OpenAI Codex lead Tibo posted "6.1 coming soon" while GPT-6.1 Sol was already shipping, and X immediately guessed Astra. His reply to the first thread answers most of it. Here is the evidence for each reading, what the cancelled 6.1 Astra changes, and what to do in the meantime.

Oct 4, 2026

Is the Terminal Era Over for Coding Agents? The Agent Is the New Primitive

On October 3, 2026 an AI systems engineer wrote that the terminal is the wrong interface for coding agents because tabs are ephemeral while context is persistent. The post passed 800K views and drew a reply from Claude Code's creator. We unpack the claim, the best counterarguments, and a practical way to choose between terminal, desktop app and orchestrator.

Oct 3, 2026

ChatGPT Sites on Hacker News: What Builders Should Know

On October 3, 2026, "Sites in ChatGPT" climbed Hacker News with roughly 209 points and 218 comments. This is the practitioner read: prototypes vs production, export vs custom domain, showcase slop, Artifacts comparisons, and lock-in — grounded in OpenAI's Sites docs, not a product-page rewrite.