CSS colours: hex, RGB, HSL and alpha
You want a dark panel over a photograph so the caption stays readable. You
write opacity: 0.5, and the panel goes see-through — along with the caption,
the price and the button, all of them now a ghostly half-there mess.
That is one property away from being right, and the difference between the two is worth understanding properly, because it comes up constantly.
Before any of that: the property is spelled color, American style. British
spelling is not accepted by the parser. The word in prose is colour; the word
in the stylesheet is color, and you get used to it.
.price {
color: seagreen;
}
Four ways to say the same colour
CSS gives you several notations, and they are interchangeable. These four produce a pixel-identical orange-red:
.a { background: tomato; }
.b { background: #ff6347; }
.c { background: rgb(255 99 71); }
.d { background: hsl(9 100% 64%); }
Keywords are around 150 named colours. Handy for a quick test, useless for
a real palette — tomato and coral tell you nothing about how they relate.
Hex is three pairs of hexadecimal digits for red, green and blue, each
running 00 to ff (0 to 255). #ff6347 is a lot of red, a moderate amount
of green, slightly less blue. When all three pairs double up you may shorten
it: #ff6633 becomes #f63. #333 is #333333, a dark grey.
rgb() is the same three numbers in decimal. Modern CSS separates them
with spaces; the older comma form rgb(255, 99, 71) still works everywhere and
you will meet it in old code.
hsl() is different in kind, and worth the effort.
HSL, and why it is easier to think in
hsl() takes hue, saturation and lightness.
- Hue is a position on the colour wheel, 0 to 360. Red is 0, green 120, blue 240.
- Saturation is how vivid, 0% being grey.
- Lightness is 0% black through 50% pure colour to 100% white.
Now do something you cannot do with hex. Keep hue and saturation fixed, change only lightness, and you get a set of colours that obviously belong together:
.tint { background: hsl(210 65% 70%); }
.base { background: hsl(210 65% 50%); }
.shade { background: hsl(210 65% 30%); }
One line each, and it is a real three-step blue ramp — light, mid, dark. To
build the same thing in hex you would be guessing at #84b3e4, #3b8ad9,
#1b4d7e and hoping they matched.
The ramp also makes a readability point immediately. On the 30% shade, white text sits at roughly 8.7:1 and is very comfortable. On the 70% tint, white text drops to about 2.2:1 and is close to unreadable, while black text is excellent. The hue never changed; the correct text colour flipped anyway.
Transparency belongs in the colour
Every colour notation takes a fourth value, the alpha: 0 is fully transparent, 1 is fully opaque.
.overlay { background: rgb(0 0 0 / 40%); }
.tinted { background: hsl(210 65% 50% / 0.25); }
.faint { border-color: #33333380; }
Three ways of saying the same idea. The slash separates the alpha from the
colour, and you may write it as a percentage or a decimal. In hex, a fourth
pair of digits does it — 80 is roughly half of ff.
If you have seen rgba() and hsla() in older stylesheets, those are the
same thing under old names. They still work, and there is no reason to write
them in new code.
The fade that swallowed the text
Here is the mistake that started this lesson.
You want a dark caption panel over a photograph. The instinct is opacity,
because "opacity" is the word for see-through:
.card {
background: black;
opacity: 0.5; /* the wrong tool */
padding: 1rem;
}
<div class="card">
<p>Karachi to Hyderabad — 09.40</p>
<button>Book seat</button>
</div>
The panel does go half-transparent. So does the paragraph. So does the button, including its border and its label. Everything inside is now washed out, and turning the text pure white does not help at all.
The reason is what opacity actually does: it takes the element and every
descendant, renders the whole lot as one flat picture, and then fades that
picture as a unit. The children are already painted into the image by the time
the fading happens, so nothing you do to a child can escape it. That also means
opacity: 1 on the paragraph does not rescue it — there is no way to be more
opaque than the group you were flattened into.
An alpha value works completely differently. It is part of one colour, and it affects only the thing that colour was painting:
.card {
background: rgb(0 0 0 / 0.5); /* the right tool */
padding: 1rem;
}
Now the panel is half-transparent and the photograph shows through it, while
the paragraph is solid, the button is solid, and the caption is readable. The
background declaration never touched them.
The rule of thumb: if you want to see through a background, put the alpha in
the background colour. Reach for opacity only when you genuinely mean the
whole element and its contents together — fading a card out on hover, or
dimming a disabled control where fading the label is exactly the point.
There is one detail worth knowing while you are here. An element with
opacity less than 1 creates a stacking context, which can quietly change how
it layers against other positioned elements. Alpha colours do not. That is
another small reason to prefer the colour when the colour is what you meant.
Contrast is a number, not a feeling
Light grey text on white looks tasteful on a bright laptop indoors. On a phone outdoors, or to a reader with slightly reduced vision, it is close to invisible.
The measure is a contrast ratio, from 1:1 (identical) to 21:1 (black on white). The accessibility threshold for ordinary body text is 4.5:1, and for large text — around 24px, or 19px bold — it is 3:1.
.note { color: #999; } /* on white: about 2.8:1 — fails */
.note { color: #767676; } /* on white: about 4.5:1 — the lightest grey that passes */
#767676 is a genuinely useful number to remember: it is roughly the lightest
grey that still passes on a white background. Anything lighter is decoration,
not text.
Devtools will do the arithmetic for you. Inspect an element, click the colour swatch in the Styles panel, and the picker shows the contrast ratio against the background it actually computed, with a tick or a cross.
Two things trip people up. The check is against the real background — if your
panel is rgb(0 0 0 / 0.4) over a photo, the effective background changes with
whatever the photo is doing underneath. And placeholder text, disabled labels
and footer small print are the places designers reach for pale grey most often,
which makes them the places worth checking first.
One property that saves repetition
currentColor is a keyword meaning "whatever color is on this element right
now". It inherits, so a component can be recoloured from a single declaration:
.badge {
color: hsl(9 100% 40%);
border: 2px solid currentColor;
background: hsl(9 100% 40% / 0.12);
}
.badge.quiet {
color: dimgray; /* border follows automatically */
}
The border tracks the text without being named twice. Change one line, and the
outline, the text and anything else using currentColor all move together.
Next: units, where 0.12 above turns out to be one of
several numbers in CSS that mean different things depending on where you write
them. If a colour rule is loading but not applying, the combinators
lesson covers the selector side, and file
paths covers the case where the stylesheet never
arrived.
Prefer learning with a teacher?
WasiLearn Academy runs small-group classes covering this material, online and in Karachi.
See the classes