Colour

Write colour in the notation that suits the job, and check that text is readable.

07of 26
≈ 10Min read
8Parts

The notations

the same grey, five ways
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);
NotationGood for
#rrggbbCopying from a design tool. Opaque, compact, unreadable by eye.
#rrggbbaaThe 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.

a palette from one hue
: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.

a ramp that stays even
: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

transparency
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);
Translucent beats hard-coded

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.

the icon follows the text
.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.

derive instead of hard-coding
--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.

light and dark without duplicating everything
: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); }

Practice