WasiLearn Code
CSS · 2. Colour, text and units

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