Skip to main content
CSS

RGBA colors: the alpha channel is a mix ratio, not a dimmer switch

The fourth value in rgba() doesn't lighten your color — it blends it with whatever the browser already painted underneath. Here's how alpha compositing actually works, and the four places it bites.

Thien Nguyen
By Thien Nguyen
Updated August 2, 2026 · 9 min read

Put a panel with rgb(255 255 255 / 0.5) on a white page and nothing visible happens. Put that exact declaration on a black page and you get a bright grey slab. Same color, same alpha, opposite outcomes.

That's the tell that the common mental model is off. The model goes: the fourth value is transparency, so lower numbers mean lighter, more washed out, further toward invisible. Alpha is transparency — that part is fine. What's wrong is treating it as a brightness knob bolted onto your color. It isn't attached to your color at all. It's a mix ratio between your color and whatever is already painted underneath, and the backdrop is usually the half you don't control.

If you want the wider survey of hex vs. rgb() vs. HSL vs. OKLCH and when each one is the right literal to write, that's its own article. This one is only about the fourth value.

The equation you're actually invoking

Compositing a translucent layer over an opaque backdrop is source-over, which per channel is just a weighted average:

result = alpha × source + (1 − alpha) × backdrop

Alpha is the weight. At 0.5 you get the midpoint between your color and the backdrop. At 0.9 you get mostly your color. At 0 the backdrop passes through untouched. Nothing in that equation knows or cares what "lighter" means — it only knows two colors and how far to walk between them.

Worked example: one declaration, four different colors

Here is rgb(0 0 0 / 0.5) — a plain 50% black scrim, the single most-used translucent value on the web — sitting on four different backdrops.

BackdropBackdrop RGBPer-channel mathRendered result
White255, 255, 2550.5 × 255 in all three#808080 — mid grey
Blue #2563eb37, 99, 2350.5 × 37 = 18.5 · 0.5 × 99 = 49.5 · 0.5 × 235 = 117.5#133276 — dark navy
Orange #f97316249, 115, 220.5 × 249 = 124.5 · 0.5 × 115 = 57.5 · 0.5 × 22 = 11#7d3a0b — muddy brown
Black0, 0, 00.5 × 0 in all three#000000 — invisible

The results are approximate because most of those channels land on a half-value and engines differ on which way they round, but the point survives the rounding: a "50% black overlay" is mid grey, dark navy, brown, or nothing at all depending entirely on what it's covering. It is not one color that you dialed down. It is four different colors that happen to share a declaration.

Run it the other way and it's just as stark. rgb(255 255 255 / 0.5) over that same #2563eb blue gives you roughly #92b1f5 — a pale periwinkle. Over white it gives you white.

If you find yourself tweaking an alpha value until a color "looks right," stop and check what's behind it. You're not adjusting a color, you're adjusting how much of someone else's color shows through — and that backdrop can change when a designer swaps a hero image or a user flips to dark mode.

Where this is genuinely the right tool

The compositing behavior isn't a hazard to route around. It's the feature, and four patterns lean on it directly.

Scrims over unpredictable backdrops. A rgb(0 0 0 / 0.45) layer between a hero photo and its headline works precisely because it doesn't know what's underneath. It compresses whatever range the image has toward black, which drags the whole photo down and buys the white text its contrast — on every image, without you sampling a single one.

Translucent panels. The glassmorphism look is a low-alpha fill plus backdrop-filter: blur(). The panel's tint has to come from the content scrolling behind it, so a solid color is structurally the wrong answer here.

Hover and pressed states without a second token. This is the one I reach for most:

.btn {
  background: var(--brand);
}
.btn:hover {
  background: linear-gradient(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.12)), var(--brand);
}

One overlay darkens every button variant by the same perceptual step. The alternative is a hand-picked --brand-hover for each of your brand colors, all of which drift the first time someone retunes the palette. If you're generating those scrims from an existing brand hex, a hex to rgba converter saves the mental arithmetic.

Fades that end in nothing. A gradient stop at zero alpha lets a shadow or a scrim taper off over arbitrary content, which a fade to a guessed solid color can never do convincingly.

rgba() is now literally an alias for rgb()

CSS Color Module Level 4 folded the alpha channel into rgb() itself, using a slash separator, and made the space-separated form legal:

/* all four of these are the same color */
.a { color: rgba(37, 99, 235, 0.8); }
.b { color: rgb(37, 99, 235, 0.8); }
.c { color: rgb(37 99 235 / 0.8); }
.d { color: rgb(37 99 235 / 80%); }

MDN is blunt about it: "The rgba() functional notation is an alias for rgb(). They are exactly equivalent." Same for hsla() and hsl(). So the separate four-argument function is technically redundant now.

Support for the space-separated syntax landed in Chrome 65, Firefox 52, Safari 12.1 and Edge 79 — caniuse put it around 94% of global traffic when I checked in August 2026. That's old enough that the modern form is safe in new code.

Should you go rewrite every rgba() in your codebase? No. It's an alias, not a deprecation; both parse to an identical computed value and there is no perf or correctness difference. But it's worth adopting going forward for one concrete reason: every color function CSS has shipped since — oklch(), lab(), color() — uses slash-alpha and only slash-alpha. There is no oklcha(). Writing rgb(37 99 235 / 0.8) means your alpha syntax looks the same everywhere in the stylesheet instead of splitting along the year each function was standardized.

Four things that go wrong

Two 50% layers do not make 100%

Stacking translucent layers is not additive. Each layer composites against the result of the one below it, so the combined alpha is 1 − (1 − a₁)(1 − a₂). Two 50% black layers give you 75%, not 100%. Three give 87.5%. You can stack these forever and never reach opaque.

Concretely: two rgb(0 0 0 / 0.5) layers over white land on #404040 — identical to a single rgb(0 0 0 / 0.75). This bites when a scrim gets applied at two levels of a component tree and the design comes out darker than either developer intended, or when someone tries to "fix" a too-light overlay by adding a second one and gets a nonlinear result.

A translucent border paints on top of the background it's supposed to sit beside

This one is subtle and it shows up in every glassmorphism card. background-clip defaults to border-box, which means the background is painted across the full border box — underneath the border, not up to it. With opaque colors you never notice. With translucent ones:

.glass-card {
  background: rgb(255 255 255 / 0.1);
  border: 1px solid rgb(255 255 255 / 0.2);
}

In the border region the browser composites the 0.1 background and then the 0.2 border on top of it, so the effective alpha there is 1 − (0.9 × 0.8) = 0.28, not the 0.2 you asked for. The border reads brighter than specified, and the gap widens as you raise either value. The fix is one line:

.glass-card {
  background-clip: padding-box;
}

opacity is not a shorter way to write rgba()

They look interchangeable on a solid card and are not remotely the same operation. background-color: rgb(255 255 255 / 0.5) makes one paint operation translucent. opacity: 0.5 renders the element and its entire subtree to a buffer, then composites that whole buffer at 50%. Two consequences:

  • It takes the text with it. Your labels, icons and child images all fade too, and a child cannot opt out — MDN is explicit that setting opacity: 1 on a descendant won't restore it, because the effect is applied to the group after the fact.
  • It creates a stacking context. Any value other than 1 does this, and it's a classic source of "why does z-index no longer order these correctly." A descendant with a huge z-index can't paint above a later sibling of the faded element any more, because its z-index is now scoped to the new context. You'll go hunting through positioning code for a bug that was caused by a decorative fade three ancestors up.

Use opacity when you mean fade this whole thing out, as in a disabled state or a transition. Use alpha in the color when you mean make this one surface translucent.

transparent is transparent black

The transparent keyword computes to rgb(0 0 0 / 0) — zero-alpha black, not some neutral absence of color. That's why the folk wisdom exists that linear-gradient(#f97316, transparent) "fades through grey": if you interpolate those endpoints naively, the RGB channels march from orange toward 0, 0, 0 while alpha falls, and the midpoint is a dirty brown-grey haze.

The spec closed this. CSS Images Module Level 3 requires gradient color stops to be interpolated in premultiplied RGBA space, which weights each channel by its own alpha before mixing — so a stop that's fully transparent contributes no color at all, only opacity. The spec text notes this is exactly what makes transitions to and from transparent "always look nice."

Which means the advice you'll still find repeated everywhere — always spell it out as rgb(249 115 22 / 0) instead of transparent — is fixing a problem the spec has since defined away. Writing the zero-alpha version explicitly is still perfectly good, self-documenting CSS. Just know you're doing it for readability, not to dodge a rendering bug.

The short version

Alpha is a blend weight, not a brightness adjustment. Before you ship a translucent value, you need an answer to one question: what is it landing on, and can that change? If the backdrop is a fixed token you control, alpha is a clean way to derive states without inventing new colors. If it's a user-supplied image or a theme that flips, the same declaration is going to render as a different color for different people — which is fine when it's a scrim doing its job, and a bug when you assumed it was a color.

When you need to move a value back the other way — an rgba() declaration into hex notation for a design token or a config file that won't take CSS syntax — the rgba to hex converter handles the eight-digit form.


Cover photo by Jan van der Wolf on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

CSSWeb DesignColors

Related articles

All articles