The notations
color: #15161a;
color: rgb(21 22 26);
color: rgb(21 22 26 / 0.8); /* with alpha */
color: hsl(228 11% 9%);
color: oklch(0.21 0.008 265);
| Notation | Good for |
|---|---|
#rrggbb | Copying from a design tool. Opaque, compact, unreadable by eye. |
#rrggbbaa | The same with alpha. #00000080 is black at 50%. |
rgb() | Mixing from numbers. Modern syntax drops the commas and takes / alpha. |
hsl() | Building a palette by hand: hue, saturation, lightness are three things you can reason about. |
oklch() | Perceptually even. Two colours with the same L really do look equally bright. |
hsl is the one you can think in
Hue is a wheel in degrees — 0 red, 120 green, 240 blue. Saturation is 0% grey to 100% vivid. Lightness is 0% black to 100% white. To make a hover state slightly lighter you change one number, which is impossible to do by eye in hex.
:root {
--brand-h: 18;
--brand: hsl(var(--brand-h) 86% 53%);
--brand-dark: hsl(var(--brand-h) 86% 43%);
--brand-tint: hsl(var(--brand-h) 86% 96%);
}
Why oklch is better than hsl
In HSL, hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) claim the same lightness, and the yellow is blinding while the blue is nearly black. HSL lightness is a number, not a perception.
OKLCH fixes that: L is perceived lightness, C is chroma, H is hue. Two colours with the same L are genuinely equally light, which is what makes generating an accessible palette possible.
:root {
--step-1: oklch(0.98 0.01 265);
--step-5: oklch(0.72 0.04 265);
--step-9: oklch(0.28 0.03 265);
}
Alpha
background: rgb(0 0 0 / 0.5);
background: hsl(228 11% 9% / 0.85);
background: #00000080;
/* a border that darkens whatever is behind it, on any background */
border: 1px solid rgb(255 255 255 / 0.12);
A border of rgb(255 255 255 / 0.12) works on every background you ever put it on. A fixed #2a2a2e works on exactly one.
currentColor
currentColor is the element's own color value, usable anywhere a colour is accepted. It is how one icon serves every theme.
.button {
color: #15161a;
border: 1px solid currentColor;
}
.button svg {
fill: currentColor; /* or stroke */
}
.button:hover { color: #c2452d; } /* border and icon follow */
color-mix
This is how a theme stays coherent: one source colour, everything else derived from it. Change the source and the whole set moves together.
--line: color-mix(in srgb, var(--ink) 12%, transparent);
--hover: color-mix(in oklab, var(--brand) 85%, white);
--muted: color-mix(in oklab, var(--ink) 62%, var(--bg));
Contrast is not optional
Text must reach a contrast ratio of 4.5:1 against its background; large text (24px, or 19px bold) needs 3:1. Interface components and focus indicators need 3:1.
Grey-on-grey placeholder text and pale hint text are the commonest failures, and they fail for everyone in bright sunlight, not only for people with low vision.
- Check in devtools: the colour swatch in the Styles pane shows the ratio and whether it passes.
- Never rely on colour alone: a red border on an invalid field needs a message beside it.
- Check the focus ring too — a ring that disappears on your background is a ring that is not there.
Schemes
color-scheme is the line people miss: without it the native controls, the scrollbars and the default form colours stay light while everything you styled goes dark.
:root {
color-scheme: light dark; /* form controls and scrollbars follow too */
--bg: #ffffff;
--ink: #15161a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0b0d;
--ink: #e9ebee;
}
}
body { background: var(--bg); color: var(--ink); }