# The Color Is Right but the Contrast Is Wrong: The Accessibility Trap CSS opacity Sets

> You put a dark gray in your CSS, yet the accessibility checker flags it for low contrast. The culprit is opacity. Here's how opacity mixes your text color into the background and quietly kills contrast — with a real case, the math, and the fix.

**Published:** 2026-07-21 | **Updated:** 2026-07-21

---


I had clearly written a dark gray into my CSS. `color: #595959`. On a white background that color has a contrast of 6.4:1, which comfortably clears the accessibility bar of 4.5:1. Pass.

Then I ran an accessibility checker, and a red line appeared.

> This text has a contrast of 4.04:1, which is insufficient. Expected ratio: 4.5:1.

I put in a color that measures 6.4:1, so why is it calling it 4:1? Was the checker wrong?

It wasn't. I was — and the culprit was a single line sitting quietly two rows away: `opacity: 0.8`.

## What was contrast ratio again

Let me pin down a term first. **Contrast ratio** is a number from 1:1 to 21:1 that expresses how far apart the brightness of your text color and background color are. Black text on a white background is the maximum, 21:1; the same color against itself is 1:1.

**WCAG** (Web Content Accessibility Guidelines) requires **at least 4.5:1** for normal-size body text. Large text (24px and up, or bold 18.7px and up) only needs 3:1. The reason for the bar is simple: a low-vision reader, or someone squinting at a screen in bright sunlight, has to be able to read the letters.

So 6.4:1 is a reassuring number. The trouble was that the color actually painted to the screen was not the 6.4:1 one.

## opacity isn't "transparency," it's "color mixing"

We usually understand `opacity` like this: "it makes an element a little faded." Fade-ins, a disabled feel, subtle secondary text. To the eye it just gets lighter.

But to the browser, `opacity: 0.8` is a command to "mix this element with the background at 80:20." It's like adding water to paint. The ink (your text color) itself is unchanged, but as the water (background color) blends in, it gets lighter. If the paper is white it turns pale; if the paper is gray it gets pulled toward gray.

Written as a formula, it looks like this. This is called **alpha compositing** — computing a final color by laying a translucent color over a background.

```text
composited color = textColor × α + background × (1 − α)
```

`α` (alpha) is the opacity, the `opacity` value. Let me plug in my case. The text color `#595959` is `(89, 89, 89)` in RGB, the background is `#f5f5f3` = `(245, 245, 243)`, and opacity was `0.8`.

```text
R: 89 × 0.8 + 245 × 0.2 = 71.2 + 49.0 = 120.2 ≈ 120
G: 89 × 0.8 + 245 × 0.2 = 120.2 ≈ 120
B: 89 × 0.8 + 243 × 0.2 = 119.8 ≈ 120
→ composited color = (120, 120, 120) = #787878
```

`#787878`. The exact color the checker printed in its report. It matched down to the last digit. And when you take that color and compute contrast against the `#f5f5f3` background again, you get **4.04:1** — the number the checker named.

{{< img src="images/contents/opacity-dilution-en.png" alt="A diagram showing how opacity dilutes color contrast - on the left, a swatch of the color written in CSS, #595959, is labeled 6.42 to 1, pass; passing through a multiply-by-opacity-0.8 arrow in the middle; on the right, the composited color actually drawn on screen, #787878, is labeled 4.04 to 1, fail. Below is the compositing formula" caption="The color you write in CSS and the color that actually gets drawn can differ. opacity slips in between them." >}}

Here's the crux. Whatever value you put in `color`, **what gets drawn to the screen is the result after opacity is mixed in**. Contrast is judged not on the color you wrote in code, but on the color that actually reaches the user's eye. It sounds obvious, but when you're reading code it's all too easy to see the single line `color: #595959` and think "6.4:1, we're good" and move on.

## The trap comes with three faces

The same principle hides in slightly different shapes. Keep three in mind and you'll catch most of them.

### 1. opacity on the element itself

This was my case. The `opacity` sits directly on the text element.

```css
.post-date {
  color: #595959; /* look only here and it seems like 6.4:1, a pass */
  opacity: 0.8;   /* but this line drags the real color down to #787878 */
}
```

Because the color and the opacity are side by side in the same rule, this one is relatively easy to find. The next two are the problem.

### 2. opacity on an ancestor — and it multiplies

`opacity` applies not to one element but to **its entire subtree**. Put `opacity: 0.9` on a parent and every piece of text inside is drawn at 90%. And if a child has its own opacity too, the two values **multiply**.

```css
.card       { opacity: 0.9; }        /* dim the whole card slightly */
.card .date { color: #595959;
              opacity: 0.8; }         /* dim the date once more */
/* effective alpha = 0.9 × 0.8 = 0.72 */
```

`0.9 × 0.8 = 0.72`. Compute with that final alpha and `#595959` becomes `#858584`, and the contrast falls all the way to **3.38:1**. Yet open the `.date` rule alone and it says opacity `0.8`, so from the code you have no idea why it's this faint. The culprit is hiding two files over, up in an ancestor.

### 3. The alpha in rgba / hsla — the same trap by another name

Don't relax just because you didn't use `opacity`. Putting alpha into the color itself with `rgba()` or `hsla()` does exactly the same thing.

```css
.muted { color: rgba(0, 0, 0, 0.5); } /* "translucent black" */
```

"50% black" sounds dark, but on a white background this becomes `#808080` and the contrast is **3.95:1**. A fail. `rgba(0,0,0,0.6)` barely passes as `#666` (5.7:1), but the moment you put it on a gray card instead of white it gets shaky again. The point: **a color with alpha only tells you its contrast once you know the background.**

{{< img src="images/contents/opacity-three-traps-en.png" alt="A diagram showing the three types of the opacity contrast trap side by side - first, opacity on the element itself; second, opacity on an ancestor, marked as multiplying so 0.9 times 0.8 is 0.72; third, the alpha value of an rgba or hsla color. All three converge on the same root principle: the text color mixes with the background and contrast drops" caption="Three faces, but one root: the text color blends into the background." >}}

## Why the eye can't catch it

At this point you might think: "So just check the color with dev tools, right?"

That's another trap. The contrast number the browser's color picker shows usually gets computed from **the declared `color` value and the immediately adjacent background** only. In many cases it doesn't account for `opacity` on the element or an ancestor. So dev tools flash a green "6.4:1, pass ✓," while axe-core reports "4.04:1, fail." The mismatch is born right there.

When they disagree, the checker is usually the one that's right. Tools like axe-core and Lighthouse actually render the element and then compute contrast from the **result color** with all the opacity composited in. They judge by the color a person actually sees. So when the two numbers diverge, trust the checker that looked at the composited color over the dev tools that only looked at the declared one.

Put simply:

- **The dev tools color swatch**: the color I *wrote* in code
- **The accessibility checker**: the color the user actually *sees*

opacity lives in the gap between those two.

## How to catch it, and how to fix it

Once you understand the principle, the response is actually simple.

**Finding it.** Search two ways.

- Automated: run axe-core, Lighthouse, or a browser-extension accessibility checker. They factor opacity in, so they're the surest way to catch this trap.
- Manual: in your CSS, search text elements and their ancestors for `opacity:` and `rgba(` · `hsla(`. Trace it: "how many times is alpha being multiplied over this text?"

**Fixing it.** There's one principle.

> If you want text to look faint, use **the faint color itself** — not opacity.

Think about it and it's obvious. If the final look you want is that pale gray, put that color into `color` from the start and meet the contrast bar with it. There's no reason to write a dark color and then shave it down with opacity. That's exactly the fix I made: I removed `opacity: 0.8` and left `#595959` as-is, and the contrast came back to 6.4:1. On screen it looks nearly identical, but the checker flipped to green.

So when *is* it fine to use opacity? **Transition states** are fine. A mid-value of a fade-in/out animation, something that just flashes by, is not something WCAG takes issue with. WCAG looks at the contrast of the **resting state**, the one that sits still. Disabled elements are exempt too — a grayed-out button you can't click is not held to the contrast bar (an explicit exception in WCAG 1.4.3).

Here's the dividing line: is it "text meant to be read," or not? If you dimmed information a user needs to read with opacity alone, that isn't subtle design — it's a quiet violation. For a low-vision reader, that 20% is the difference between "readable" and "not."

## The one-page recap

- `opacity < 1` doesn't make text faint; it **mixes the text color into the background color.** The real contrast drops.
- The formula is `composited = textColor × α + background × (1 − α)`. In my case `#595959` turned into `#787878` at `opacity 0.8`, plunging from 6.4:1 to 4.04:1.
- Three faces to the trap: ① opacity on the element itself ② opacity on an ancestor (values **multiply**, 0.9×0.8=0.72) ③ the alpha in `rgba`/`hsla`.
- The dev tools swatch shows the *declared* color; the accessibility checker shows the *composited* color. When they disagree, trust the checker.
- To make text faint, use a **verified color**, not opacity. Save opacity for fleeting states like transition animations.

There's no guarantee that the value you put in `color` is the color your user sees. If opacity is wedged in between, contrast gets recomputed on the screen, not in the code. Once you've picked a color, take a moment to check that the color reaches the screen intact.

Other posts in this series deal with color and contrast too. [Implementing Dark Mode Properly](/en/posts/dark-mode-implementation/) covers token design that keeps contrast even in a dark palette, and [Supporting High Contrast Mode Properly](/en/posts/high-contrast-mode/) covers what happens when the OS forces the colors. You can keep reading in [the full Frontend × Accessibility series](/en/series/frontend--accessibility/).

