Contrast Without the Beige: An OKLCH Palette Workflow for UK Public-Sector Sites
Photo: Bill Ingalls, Public domain, via Wikimedia Commons
Accessible colour has a reputation problem. Say the words WCAG 2.2 in a UK studio and someone will reach for a safe navy, a muted grey and a link colour that has had all the joy sanded off it. The result passes an audit, but it often looks like a council tax reminder from a decade nobody wants to remember.
At CodeChroma we like colour too much to accept that trade-off. The good news is that you do not need to choose between a rich palette and readable text. You need a better workflow: perceptual colour spaces, functional tokens and a contrast check that lives inside your browser rather than at the end of the project.
The beige trap is a token problem
Most accessible palettes fail because teams pick one brand colour and use it for everything: buttons, links, headings, focus rings and tiny helper text. A single hex value cannot do all those jobs. A blue that looks great as a large hero graphic may be hopeless as 14px body copy on white.
The GOV.UK Design System solved this structurally. Love or loathe the look, its discipline is worth borrowing: separate colour roles, plain surfaces, a loud focus state and text colours chosen for reading rather than decoration. Do not copy the palette wholesale. Copy the idea that colour is a system of jobs, not a mood board.
OKLCH gives you a dial, not a dice roll
CSS OKLCH is a perceptual colour model with three values: lightness, chroma and hue. Lightness runs roughly from 0 to 1, chroma controls intensity, and hue is an angle. That matters because changing lightness in OKLCH feels more even than dragging a hex slider around. You can darken a brand colour for text without it turning muddy or neon.
Relative colour syntax makes this practical:
.link:hover {
color: oklch(from var(--brand) calc(l - 0.12) c h);
}
That snippet takes the brand token, drops its lightness and keeps the chroma and hue. The result is still recognisably the same family. You can build hover states, borders and focus rings from a small set of base tokens instead of hand-picking twenty hex values.
A GOV.UK-inspired token skeleton
Start with roles, not colours. A useful set for a UK service site looks like this: paper, ink, surface, border, brand, brand-strong, accent, danger and focus. The focus token is not optional. WCAG 2.2 expects focus indicators to be visible, and 3:1 contrast against adjacent colours is the practical bar to aim for.
:root {
--paper: oklch(0.985 0.008 95);
--ink: oklch(0.22 0.035 255);
--surface: oklch(0.96 0.02 250);
--border: oklch(0.76 0.03 255);
--brand: oklch(0.47 0.15 255);
--brand-strong: oklch(0.36 0.16 255);
--accent: oklch(0.76 0.17 85);
--focus: oklch(0.88 0.22 95);
--danger: oklch(0.45 0.18 25);
}
Use --brand for large graphics and generous UI shapes. Use --brand-strong for links and small text. Let --accent do decorative work, not body-copy work. If you need a tint, use color-mix(in oklch, var(--brand) 18%, var(--paper)) rather than inventing a new near-white. This is how you keep a palette colourful without letting every token become a text colour.
Before and after on real UK patterns
Picture a district council bin-day lookup. The before version uses a soft brand blue on white for the service title, the link and the small status text. It looks friendly in Figma and fails when someone checks the ratio at 14px. The after version keeps the same blue for the header band, switches links to the strong variant, gives the status text the ink token and reserves the accent for a single confirmation badge.
Now picture a housing charity donation form. The before version has a pastel green button with white label text. It feels calm, but the label is hard to read for anyone with low vision. The after version darkens the button background, keeps the pastel green as a border or illustration colour, and uses the focus token on the keyboard state. The page still feels like the charity. It just stops asking people to squint.
Check contrast in the browser with p5.js
You can wait for an audit, or you can paste colours into a tiny sketch and watch the ratio move. This p5.js sketch uses two colour pickers, calculates WCAG relative luminance and reports the contrast ratio in real time. It is deliberately rough: a maker's sanity check, not a replacement for testing with real users.
let fg, bg;
function setup() {
createCanvas(640, 360);
fg = createColorPicker('#1b2a41');
bg = createColorPicker('#f7f4ec');
fg.position(20, 20);
bg.position(20, 60);
}
function draw() {
background(247, 244, 236);
const ratio = contrast(fg.color(), bg.color());
noStroke();
fill(20);
textSize(18);
text('Contrast: ' + ratio.toFixed(2) + ':1', 20, 130);
text(ratio >= 4.5 ? 'PASS AA text' : 'CHECK text', 20, 160);
fill(fg.color());
rect(20, 190, 280, 100);
fill(bg.color());
rect(320, 190, 280, 100);
}
function contrast(a, b) {
const L1 = luminance(a);
const L2 = luminance(b);
return (max(L1, L2) + 0.05) / (min(L1, L2) + 0.05);
}
function luminance(c) {
const r = red(c) / 255;
const g = green(c) / 255;
const b = blue(c) / 255;
const lin = v => v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
Aim for 4.5:1 for normal text and 3:1 for large text and UI components. Check focus rings against both the component and the surrounding page. Then test the palette with real content: long council service names, charity donation amounts, error messages and Welsh language labels. Colour that only works with placeholder text is not a palette. It is a rumour.
Ship the palette, not a one-off hex
The lesson is simple. Accessible colour is not a retreat from colour. It is a promotion for your tokens. Build roles, use OKLCH to move lightness without losing character, and keep a contrast preview close to your design work. Your site can still look like a place people want to visit, not a form they have to survive.