#7C1E3A |
#FFFFFF |
#176457 |
#452F73 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
๐จ Palette in play โ Deep Winter Reef Couture ยท
#7C1E3A#FFFFFF#176457#452F73
Accessibility audits check contrast against the values in your design file. Users experience contrast against the pixels their browser actually decoded. Between those two things sits an encoder that was asked to make the file smaller, and it does that by changing colours.
ย
Most of the time the change is too small to matter. In three specific situations it is enough to push a passing combination into failure โ and those situations are common.
ย
๐ The starting point: values that pass comfortably
This palette is deliberately high-contrast, which is exactly what you want for text over imagery:
| Text | Background | Ratio | Grade |
|---|---|---|---|
#FFFFFF
|
#7C1E3A
|
9.98:1 | โ AAA |
#FFFFFF
|
#176457
|
7.01:1 | โ AAA |
#FFFFFF
|
#452F73
|
11.01:1 | โ AAA |
Plenty of headroom. Now put that white text inside a JPEG instead of in HTML, and watch what compression does to it.
ย
๐จ The three ways compression eats contrast
1 ยท Ringing around text
JPEG and other DCT-based codecs work in 8ร8 blocks. A hard edge โ white letterform against a dark background โ produces high-frequency information that quantisation can't represent exactly, so the decoder reconstructs it with overshoot: faint dark halos inside the light stroke and light halos outside it.
ย
The consequence for accessibility: the effective colour of a thin white stroke is no longer #FFFFFF. Sample the middle of a 2px stroke in a heavily compressed JPEG and you may find #E4E0E2 โ which drops white-on-burgundy from 9.98:1 to 7.63:1. Still fine here. Start from a combination at 4.6:1 and the same effect puts you under the line.
2 ยท Chroma subsampling shifting the colour, not the brightness
At 4:2:0, colour is stored at quarter resolution. Around coloured text this produces fringing โ and if your foreground and background differ mainly in hue rather than lightness, subsampling attacks exactly the channel carrying the distinction.
ย
This is the strongest argument for a rule you already know: contrast should come from lightness, not hue. A design that satisfies WCAG through a big lightness gap is robust to compression. One that scrapes past on a saturated-hue difference is not.
3 ยท Banding in gradients behind text
A hero with a subtle dark gradient behind white text can band after encoding. Each band is a slightly different background value, so the contrast ratio varies across the headline โ and the audit, which sampled one point, never saw it.
ย
๐ก๏ธ The fix that solves all three at once
Don't put text inside the image.
<!-- โ text baked into the JPEG: contrast depends on the encoder -->
<img src="hero-with-headline.jpg" alt="Winter collection โ now available">
<!-- โ
text in HTML over the image: contrast is exact, selectable, translatable -->
<figure class="hero">
<img src="hero.avif" alt="" width="1600" height="900">
<figcaption class="hero__text">Winter collection โ now available</figcaption>
</figure>
.hero { position: relative; isolation: isolate; }
.hero img { display: block; inline-size: 100%; block-size: auto; }
.hero__text {
position: absolute;
inset-block-end: 0;
padding: 2rem;
color: #FFFFFF;
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 650;
/* a scrim guarantees the ratio regardless of what the photo does */
background: linear-gradient(to top, rgb(124 30 58 / 0.92), rgb(124 30 58 / 0));
}
The scrim is the important part: it pins the effective background to a value you chose โ here #7C1E3A at 92%, giving white text a computable ratio โ instead of whatever the photograph happens to contain after compression.
ย
Benefits beyond contrast: the text is selectable, translatable, searchable, resizable and readable by a screen reader without duplicating it in alt.
ย
๐๏ธ When the text really must be in the image
Social cards, email headers, some ad formats. Then:
$ # 1 ยท lossless or near-lossless for anything with text
magick card.png -define png:compression-level=9 card-out.png
magick card.png -quality 95 -sampling-factor 4:4:4 card-out.jpg
$ # 2 ยท never lossy WebP for text (it is always 4:2:0)
cwebp -lossless card.png -o card.webp
$ # 3 ยท give the text more headroom than the audit requires
design at โฅ 7:1 so a compression loss of ~1.5 points still clears 4.5:1
$ # 4 ยท check the compressed file, not the source
That fourth line is the whole discipline. Audit the artefact you ship.
ย
๐งช Measuring contrast on the file you actually serve
// contrast-after-encode.mjs โ sample the real pixels of the delivered image
import sharp from 'sharp';
const luminance = ([r, g, b]) => {
const f = (c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return +((hi + 0.05) / (lo + 0.05)).toFixed(2);
};
// average a small patch so a single ringing pixel doesn't skew the result
async function patch(file, left, top, size = 6) {
const { data } = await sharp(file)
.extract({ left, top, width: size, height: size })
.raw().toBuffer({ resolveWithObject: true });
const px = [0, 1, 2].map((c) => {
let sum = 0;
for (let i = c; i < data.length; i += 3) sum += data[i];
return Math.round(sum / (data.length / 3));
});
return px;
}
const fg = await patch('dist/card.jpg', 120, 300); // inside a letter stroke
const bg = await patch('dist/card.jpg', 60, 300); // background beside it
console.log('after encoding:', ratio(fg, bg)); // the number that matters
Wire that into CI for your social-card generator and a quality-setting change can never silently break a contrast requirement.
ย
ย Compression interacts with more than contrast:โฟ The rest of the accessibility angle
alt is empty, that headline does not exist for a screen-reader user.
ย
๐งพ The checklist
- Contrast must come from a lightness difference โ that's the part compression preserves best.
- Keep text in HTML over images, with a scrim that fixes the effective background.
- If text must be baked in: lossless, or JPEG q95 at 4:4:4. Never lossy WebP.
- Design to โฅ 7:1 for text over imagery so encoding losses stay clear of the limit.
- Measure contrast on the delivered file, averaged over a patch.
- Dither gradients to avoid banding โ it's a comfort issue, not just a visual one.
- Never rely on hue alone; compression degrades hue distinctions first.
High-contrast palettes make all of this easier, because they start with headroom to lose โ the deep-winter style sets on ColorFiind are built around exactly that kind of lightness separation.
ย
ย
Have you ever measured contrast on the compressed file rather than the design? The gap surprises most people. ๐




Top comments (0)