02 · Color, Backgrounds & Gradients¶
Colour in CSS used to mean hex codes copied from a design tool. Modern CSS can do much more: it can mix colours, derive a hover shade from a brand colour, work in a colour space where "lighter" actually looks lighter, and switch palettes for dark mode. It also comes with a responsibility that's easy to measure and often ignored — contrast.
Colour syntaxes¶
.a { color: #b3401a; } /* hex: RRGGBB */
.b { color: #b3401a80; } /* hex with alpha: RRGGBBAA */
.c { color: rgb(179 64 26); } /* space-separated modern syntax */
.d { color: rgb(179 64 26 / 50%); } /* with alpha */
.e { color: hsl(15 75% 40%); } /* hue, saturation, lightness */
.f { color: oklch(55% 0.18 40); } /* lightness, chroma, hue */
.g { color: currentColor; } /* the element's own `color` value */
.h { color: transparent; }
The comma syntax rgb(179, 64, 26) and rgba() still work everywhere; the space syntax
with / alpha is the modern form and is what newer functions use.
Why oklch¶
HSL looks intuitive — hue, saturation, lightness — but its "lightness" isn't how bright a
colour looks. hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) have the same
L value and wildly different perceived brightness. That makes palettes built by varying
HSL lightness uneven, and makes contrast unpredictable.
OKLCH is designed around perception:
- L — perceived lightness, 0% (black) to 100% (white).
- C — chroma, how colourful (0 is grey; practical values rarely exceed ~0.37).
- H — hue angle, 0–360.
Changing L while holding C and H gives shades that look evenly spaced, and two colours with the same L look roughly equally bright. OKLCH can also express colours outside the sRGB range for wide-gamut displays; browsers map them back to what the screen can show.
Mixing and deriving colours¶
:root { --brand: #b3401a; }
.tint { background: color-mix(in oklch, var(--brand) 20%, white); }
.hover { background: color-mix(in oklch, var(--brand), black 15%); }
.translucent { background: rgb(from var(--brand) r g b / 0.5); }
.lighter { color: oklch(from var(--brand) calc(l + 0.15) c h); }
color-mix() blends two colours in a colour space you choose. The relative colour
syntax (rgb(from ...), oklch(from ...)) takes a colour apart into channels you can
reuse or adjust with calc().
The mixing space matters. We computed several values in Chromium and read them back:
oklch(55% 0.18 40) -> oklch(0.55 0.18 40)
color-mix(in srgb, #b3401a 50%, white) -> color(srgb 0.85098 0.62549 0.55098)
color-mix(in oklch, #b3401a 50%, white) -> oklch(0.763927 0.0787013 37.4478)
hsl(15 75% 40%) -> rgb(179, 64, 26)
#b3401a80 -> rgba(179, 64, 26, 0.5)
rgb(from #b3401a r g b / 0.5) -> color(srgb 0.701961 0.25098 0.101961 / 0.5)
Two things to notice. First, computed colours are not always rgb() any more — values
from modern colour functions are serialised in their own space, which matters if
JavaScript code parses getComputedStyle(...).color expecting rgb(...). Second, the
two 50% mixes are different colours: sRGB mixing interpolates the gamma-encoded channels,
OKLCH mixing interpolates perceived lightness and hue. For tints and shades, oklch
usually looks more natural; srgb matches what older tools produced.
All of these functions were supported in both Chromium 153 and WebKit 26.6 in our testing.
Contrast¶
WCAG defines a contrast ratio from 1:1 (identical) to 21:1 (black on white), based on the relative luminance of the two colours. The AA requirements:
| Content | Minimum ratio (AA) | AAA |
|---|---|---|
| Body text | 4.5:1 | 7:1 |
| Large text (≥ 24px, or ≥ 18.66px bold) | 3:1 | 4.5:1 |
| UI components and meaningful graphics (borders of inputs, icons, focus rings) | 3:1 | — |
We implemented the WCAG formula (convert each sRGB channel to linear light, weight them
0.2126/0.7152/0.0722, then (L1 + 0.05) / (L2 + 0.05)) and ran it on some colours:
#767676 on #ffffff: 4.54:1 <- the lightest grey that passes AA on white
#777777 on #ffffff: 4.48:1 <- one step lighter: fails
#999999 on #ffffff: 2.85:1 <- common "subtle" grey: fails badly
#b3401a on #ffffff: 5.73:1
#8a2f12 on #fffdf9: 8.28:1 <- the recipe project's headings
#555555 on #fffdf9: 7.34:1 <- the recipe project's secondary text
#1a5fb4 on #ffffff: 6.29:1 <- the focus ring colour
The pass/fail line is sharp: #767676 passes and #777777 fails. Don't eyeball it —
devtools shows the contrast ratio in the colour picker for any color value, and axe
checks text contrast automatically.
Contrast isn't the only rule: don't use colour alone to convey information. A red border on an invalid field needs an error message too; a chart's lines need labels or patterns, not just hues.
Backgrounds¶
.hero {
background-color: #2b1a12; /* shows while the image loads, and behind transparency */
background-image: url("img/kitchen.jpg");
background-size: cover; /* fill the box, cropping as needed */
background-position: center 30%;
background-repeat: no-repeat;
}
/* the same in one shorthand: position / size come together */
.hero { background: #2b1a12 url("img/kitchen.jpg") center 30% / cover no-repeat; }
cover fills the box and crops; contain fits the whole image and leaves gaps. The
shorthand resets every background sub-property you don't mention, so put it before
any longhands you add.
Background images are decoration: they have no alt, aren't in the accessibility
tree, and aren't printed by default. If the image carries meaning, use an <img>.
Text over images¶
Text on a photo fails contrast somewhere unless you control it. Layer a gradient over the image — multiple backgrounds stack with the first on top:
.hero {
color: white;
background:
linear-gradient(to top, rgb(0 0 0 / 0.75), rgb(0 0 0 / 0.1) 60%),
url("img/kitchen.jpg") center / cover;
}
Gradients¶
Gradients are images generated by the browser, so they go anywhere an image can:
.linear { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
.stops { background: linear-gradient(to right, #b3401a 0 30%, #e0b457 30% 60%, #2fa98d 60%); } /* hard stops = stripes */
.radial { background: radial-gradient(circle at 20% 20%, #fff8, transparent 40%), #b3401a; }
.conic { background: conic-gradient(#b3401a 0 72%, #eadfd3 0); border-radius: 50%; } /* 72% pie chart */
.oklab { background: linear-gradient(in oklch, #b3401a, #2fa98d); }
Two stops at the same position create a hard edge, which is how stripes and simple pie
charts are made. Gradients between saturated colours in sRGB can pass through a muddy
grey in the middle; in oklch (or in oklab) interpolation keeps the midpoint vivid.
Shadows and opacity¶
.card { box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), 0 4px 12px rgb(0 0 0 / 0.08); }
.inset { box-shadow: inset 0 0 0 2px #b3401a; } /* a "border" that takes no space */
.heading { text-shadow: 0 1px 2px rgb(0 0 0 / 0.4); }
.faded { opacity: 0.6; } /* affects the element AND its children */
.glass { background: rgb(255 255 255 / 0.7); backdrop-filter: blur(8px); }
opacity fades everything including text; to fade only the background, use an alpha
colour on background instead. opacity below 1 also creates a stacking context — the
cause of a nasty z-index bug in lesson 07.
Worked example: a palette from one brand colour¶
:root {
--brand: oklch(52% 0.16 38);
--brand-hover: oklch(from var(--brand) calc(l - 0.08) c h);
--brand-tint: oklch(from var(--brand) 95% 0.03 h);
--text: oklch(25% 0.02 38);
--muted: oklch(45% 0.02 38);
--surface: oklch(99% 0.005 80);
--border: oklch(88% 0.02 60);
}
a { color: var(--brand); }
a:hover { color: var(--brand-hover); }
.notice { background: var(--brand-tint); border-left: 4px solid var(--brand); }
body { color: var(--text); background: var(--surface); }
.meta { color: var(--muted); }
Every derived colour keeps the brand's hue and changes only lightness or chroma, so the palette hangs together. After building a palette like this, check each text/background pair's contrast in devtools — perceptual lightness makes contrast more predictable, not automatically compliant.
How It Actually Works¶
A CSS colour is a point in a colour space. rgb(), hex and hsl() all describe
sRGB, the space most screens were built around. sRGB values are gamma-encoded:
channel value 128 is not half the light of 255 (it's roughly a fifth). That's why the WCAG
formula first converts channels to linear light, and why mixing in sRGB gives
different results from mixing in a linear or perceptual space.
OKLab (and its polar form OKLCH) is a space fitted to human perception data, so
equal steps look equal. When you write color-mix(in oklch, A, B), the browser converts A
and B to OKLCH, interpolates each channel (hue along the shorter arc by default), and
produces a colour in that space. At paint time, every colour is converted to the display's
colour space; colours outside the display's gamut are mapped inside it.
Gradients are painted by the same machinery: each pixel's colour is an interpolation
between stops in the gradient's interpolation space (sRGB by default for legacy syntax
compatibility, or whatever you name with in).
Common mistakes¶
- Light grey text (
#999on white) that fails contrast. - Assuming computed colours are always
rgb()in JavaScript. - Meaning carried by colour alone.
- Text over photos with no overlay.
- Using
opacityto fade a background and fading the text with it. - Background images for content — no alt text, invisible to assistive tech.
- Hard-coding every shade instead of deriving them from a few custom properties.
Exercise¶
- Pick one brand colour and build the palette from the worked example. Apply it to your recipe page.
- Check the contrast of every text colour against its background using the devtools colour picker. Fix any below 4.5:1.
- Build a hero section with a background photo, a gradient overlay and white text. Check contrast at the lightest part of the image.
- Make a CSS-only progress ring for "72% complete" with
conic-gradient, and put the text "72% complete" inside it for screen readers and everyone else. - Compare
linear-gradient(to right, blue, yellow)withlinear-gradient(in oklch to right, blue, yellow). Describe the difference in the middle.