The Day mix-blend-mode Quietly Broke My Contrast (and the Little Meter I Built to Catch It)
A blend mode that looked gorgeous on my screen was failing WCAG on a color I never explicitly chose — so I built a meter that recomputes the real contrast live.
The Risograph look depends on mix-blend-mode: multiply. You stack two ink layers, let them
multiply where they overlap, and you get that rich, slightly-off, printed-by-hand color you can’t
get from a flat fill. It is genuinely beautiful and it is genuinely an accessibility trap, and the
reason is subtle enough that I shipped the bug before I caught it.
Here’s the problem in one sentence: with a blend mode, you don’t choose the final color — the browser computes it — and WCAG contrast is measured on the final color. I picked magenta text. I picked a paper background. Both passed contrast on their own. But the magenta sat on a layer that multiplied against a chartreuse shape bleeding up from underneath, and where they overlapped the actual rendered text color was a muddy olive that no longer passed against the paper. I never typed that olive anywhere. The compositor invented it. And screen-reading the page told me nothing, because the DOM still said “magenta on paper, 4.6:1, fine.”
The math the compositor runs behind your back
Multiply is simple math: per channel, result = (a × b) / 255. Multiply any color by anything
darker than white and it gets darker; multiply two mid-tones and you can land somewhere neither of
you intended. The contrast formula (WCAG’s relative-luminance ratio) then runs on that invented
color. So to know whether my text passes, I can’t look at the values I authored — I have to
composite the layers myself, in code, and measure the result.
So I built a little meter. It takes the foreground ink, the blend mode, and whatever’s behind it, computes the actual composited color, runs the WCAG contrast ratio against the background, and tells me live whether I’m above 4.5:1.
The source
This post ships its own interactive widget (customCode.mode: "iframe"), so the meter you’ll play
with at the end of this note is exactly this code, running inside a sandboxed frame:
<div class="bw-meter" data-bespoke="blend-contrast">
<div class="bw-stage">
<div class="bw-bg"></div>
<div class="bw-ink">Aa Bg</div>
</div>
<!-- range + select controls, then a <dl> of three readouts -->
</div>
const lin = c => { c/=255; return c<=0.03928 ? c/12.92 : ((c+0.055)/1.055)**2.4; };
const L = ([r,g,b]) => 0.2126*lin(r)+0.7152*lin(g)+0.0722*lin(b);
const ratio = (a,b) => { const l1=L(a),l2=L(b); return (Math.max(l1,l2)+0.05)/(Math.min(l1,l2)+0.05); };
// multiply composite, per channel:
const composite = (fg,bg) => fg.map((c,i) => Math.round(c*bg[i]/255));
What the meter shows, every time, is that the “as authored” ratio stays comfortably above 4.5 while the “as composited” ratio dives below it the moment the behind-layer gets dark enough or the blend bites. The gap between those two numbers is exactly the bug I shipped.
How I fixed it for real
Three things. First, I added a contrast floor to the Risograph CSS: text layers get a guard so the darkest the composite can go is still AA against the page — practically, I constrain which colors are allowed behind text, not just the text color itself. Second, I stopped putting body text on blended layers at all; the blend mode now only touches decorative shapes, headings at large sizes (which get the more forgiving AA-Large 3:1 threshold), and backgrounds. Body copy renders on a flat, unblended layer where the authored color is the final color. Third, I shipped this meter as a dev tool so the next time I reach for a blend mode under text, I composite first and measure before I fall in love with how it looks.
The isolation note
Because I’m the trusted author and the per-post code is real HTML/CSS/JS, this widget ships inside
the post’s isolated boundary — here, a sandboxed <iframe> with sandbox="allow-scripts" and no
same-origin access. Its CSS can’t leak to the global nav, its JS can’t reach another post’s DOM or
the site chrome, and it can’t read cookies or storage. Public visitors run it; they just can’t
author it. (See the colophon for the full trust model.) The lesson and the mechanism
rhyme: contain the thing that could otherwise quietly corrupt everything around it.
The broader point is the same one the contrast bug taught me. A blend mode composites colors you didn’t choose; an un-scoped stylesheet composites rules you didn’t choose onto pages you didn’t mean to touch. In both cases the fix is to measure the real, composited result — not the thing you authored — and to build the boundary that keeps the surprise local.
The meter is below. Drag the behind-layer lightness down and watch the composited ratio dive under 4.5:1 while “as authored” keeps insisting everything’s fine — that gap is the bug.