Grey November Contrast: Palettes That Survive a Manchester Drizzle
Photo: Ridiculopathy, CC BY-SA 4.0, via Wikimedia Commons
Manchester in November does not give you dramatic darkness. It gives you grey light: a flat, damp, bounced illumination that makes a laptop screen look like a smudge on a window. A palette can pass a contrast checker in a bright office and still feel unreadable on the 08:12 tram, in a council office with fluorescent tubes, or on a five-year-old ThinkPad with a washed-out panel. Contrast is not a checkbox. It is a reading condition.
The checker is a starting line
WCAG 2.2 AA asks for 4.5:1 for normal text and 3:1 for large text, with similar thresholds for UI components. Those numbers matter, especially for UK public sector work. The Public Sector Bodies (Websites and Mobile Applications) Accessibility Regulations 2018 put accessibility on the procurement agenda, and many councils, NHS trusts, and arm's-length bodies now expect WCAG 2.2 AA, EN 301 549 evidence, and an accessibility statement that reflects reality.
But a pass is not a promise. Relative luminance is calculated from linearised RGB channels, weighted roughly as 0.2126 red, 0.7152 green, and 0.0722 blue. That maths tells you how much light a colour emits relative to another. It does not know that your grey text is 14px, that the background is a subtle gradient, or that the user is reading on a glossy screen under a grey sky.
Build ramps in oklch()
Hex ramps are a trap because equal steps in hex are not equal steps in perception. Two greens that look close in code can look miles apart on screen, and a ramp that seems smooth on your monitor can collapse on a cheaper display. oklch() gives you lightness, chroma, and hue, so you can build a ramp with even perceived lightness.
@layer tokens { :root { --grey-100: oklch(96% 0.005 250); --grey-300: oklch(82% 0.008 250); --grey-500: oklch(62% 0.01 250); --grey-700: oklch(44% 0.012 250); --grey-900: oklch(24% 0.015 250); --text: var(--grey-900); --text-muted: var(--grey-700); --surface: var(--grey-100); } }
Then test the pairs you actually ship, not the pretty swatches in the brand PDF. Muted text on a raised card is a different pair from muted text on the page background. Disabled buttons are a different pair again. Focus rings are a different pair, and they need to work against both light and dark surfaces.
The bright-monitor trap
Designers often test on a calibrated display in a room with controlled lighting. That is useful for colour decisions, but it is not the whole audience. A low-quality laptop panel may have poor viewing angles, a narrow gamut, and a matte coating that turns dark greys into muddy browns. A phone outdoors in winter is another environment again. Test at 50% brightness, at an angle, and on the least glamorous screen in the office. If the contrast only works on your studio monitor, it does not work.
Also test with browser zoom at 200% and with increased default font size. Text that passes at 16px can fail when it reflows into a narrow column with a background image behind it. Contrast is a property of the rendered page, not the token file.
Forced colours and prefers-contrast
Windows High Contrast and similar forced-colors modes override your palette. If your component depends on a background image, a gradient, or a custom focus ring, it can disappear. Use system colours for critical UI and test with forced colours enabled.
@media (forced-colors: active) { .button { border: 1px solid ButtonText; forced-color-adjust: none; } }
Be careful with forced-color-adjust: none; it is a sharp tool. Usually you want the browser to adjust colours, and you want your borders, icons, and focus states to remain visible. For users who ask for more contrast, prefers-contrast: more is a useful hint, though support and behaviour vary.
@media (prefers-contrast: more) { :root { --text-muted: var(--grey-900); --border-subtle: var(--grey-700); } }
A reusable contrast audit checklist
- Test every text/background pair that appears in production, including hover, focus, disabled, and error states.
- Check normal text at 4.5:1 and large text at 3:1, then check UI boundaries and focus indicators at 3:1.
- Review the palette in
oklch()and confirm the lightness steps are even enough to survive a cheap display. - Test on the worst screen you can find, at 50% brightness and at an angle.
- Test at 200% zoom and with increased default font size.
- Enable forced-colors mode and confirm buttons, links, icons, and focus rings remain usable.
- Try
prefers-contrast: moreand make sure the result is not just darker text on the same weak background. - Check gradients, overlays, and images behind text; sample the lightest and darkest points.
- Record the results in the pull request or design review, not in a spreadsheet nobody opens.
None of this replaces a proper accessibility audit, but it catches the everyday failures that make a site feel hostile on a wet Tuesday. Good contrast is not about passing a checker. It is about respecting the light your reader is actually sitting in.