← Blog

Web design

OKLCH dark mode for a night-shift dashboard: a case study

A six-person Amsterdam SaaS studio asked us to fix the glare problem. Three out of four enterprise users were on night shifts and the dashboard was still painted for an office at noon.

Jacob Molkenboer· Founder · A Brand New Company· 9 Jun 2026· 9 min
Letterpress proof sheet on ivory paper with chartreuse swatch, red wax seal and brass loupe in dusk side light.

The first hour of the engagement was a screen-share. Three engineers, three monitors, every dashboard tab open in their warehouse-ops SaaS. One engineer had pulled his display brightness down to twelve percent. The next had taped a strip of brown paper across the top quarter of his screen, exactly where the app-bar lived. The third had installed a Chrome extension that inverted colors page-wide, which had the side effect of inverting the customer's own product photos. It was 2 a.m. in Frankfurt and 8 a.m. in Bangkok and they were on shift either way. The product had a dark mode. None of them used it.

The Amsterdam studio that builds the tool — six people, three of them designers, customers spread across Northern European logistics — had run a customer survey in February. Three out of four enterprise seats reported regular night-shift use. The dashboard, technically, had a dark theme. Someone had bolted it on in 2023 by flipping the body background to #1a1a1a and trusting that prefers-color-scheme would handle the rest. It did not. Charts kept their day-mode palette. Calendar invites blinded operators at 4 a.m. The dark theme was a Frankenstein and customers were saying so on every quarterly call.

We rebuilt the entire color system in eight weeks, from token sheet to last button. Here is what we shipped, what we measured during a twelve-week post-launch cohort, and what we would do differently if we ran the project again.

OKLCH over a hex palette

We could have shipped a hex palette in a Figma file and been done in a fortnight. We didn't, for two reasons.

The first reason was perceptual consistency. The design lead wanted "primary blue" and "primary green" to read at the same intensity in the same role, and to do so in both themes. In sRGB or HSL, that is not actually possible. HSL claims a lightness of 50% is 50%, but the human eye disagrees. Yellow at HSL 50% reads as nearly white. Blue at HSL 50% reads as nearly black. You cannot build a serious token system on top of a color space that lies to you about brightness.

The second reason was reach. Their roadmap had three new locales — Thai, Arabic, Chinese — with branded product surfaces inside the dashboard. Brand accents needed to sit at the same perceptual weight in every theme on every customer's monitor. OKLCH was the only browser-native color space we could find with uniform perceptual lightness across hues. Evil Martians has written the canonical primer on this, and we leaned on their OKLCH picker throughout the audit.

Browser support was the other thing that had quietly changed. MDN's compatibility table shows OKLCH shipped in every major browser by late 2023. By the time we started, the customer base was Chromium plus Safari at fleet-issued versions. We did not need a fallback.

The token sheet, in three layers

We landed on a three-layer token model — primitives, semantic roles, and component slots — because anything flatter is impossible to refactor and anything deeper is impossible to audit at a glance.

Primitives are pure OKLCH values, named only by hue family and step. They live in CSS custom properties on :root and never appear in component CSS.

:root {
  /* Neutral ramp — same hue, walking lightness */
  --neutral-0:   oklch(98%   0.005 250);
  --neutral-10:  oklch(94%   0.008 250);
  --neutral-20:  oklch(86%   0.010 250);
  --neutral-40:  oklch(65%   0.014 250);
  --neutral-60:  oklch(45%   0.016 250);
  --neutral-80:  oklch(25%   0.014 250);
  --neutral-90:  oklch(16%   0.012 250);
  --neutral-95:  oklch(10%   0.010 250);

  /* Brand primary — same chroma, walking lightness */
  --brand-30:    oklch(45%   0.18  254);
  --brand-50:    oklch(62%   0.20  254);
  --brand-70:    oklch(78%   0.16  254);

  /* Signal hues — danger, warning, success — locked to L=62% */
  --signal-danger:   oklch(62%   0.22  28);
  --signal-warning:  oklch(62%   0.16  85);
  --signal-success:  oklch(62%   0.15  148);
}

Semantic roles are what the team actually consumes. They reference the primitives and flip per theme.

[data-theme="light"] {
  --surface-base:   var(--neutral-0);
  --surface-raised: var(--neutral-10);
  --text-primary:   var(--neutral-90);
  --text-muted:     var(--neutral-60);
  --border-subtle:  var(--neutral-20);
  --action-primary: var(--brand-50);
}

[data-theme="dark"] {
  --surface-base:   var(--neutral-95);
  --surface-raised: var(--neutral-90);
  --text-primary:   var(--neutral-10);
  --text-muted:     var(--neutral-40);
  --border-subtle:  var(--neutral-80);
  --action-primary: var(--brand-70);
}

Component slots wrap the role. A button reads --btn-bg, not --action-primary. That lets a designer tune one button without touching the role and breaking every other surface downstream of it.

One detail that mattered: brand primary uses a different step in dark mode, not an inverted lightness. The common dark-mode shortcut is "just flip L around 50". It is wrong. The same chroma at a higher lightness, surrounded by deep neutrals, is what registers as "the same blue" to a night-shift operator. Inverting the lightness gives you a different brand color entirely.

WCAG 2.2 AA on every surface, not just the marquee ones

The studio's old contrast story was the usual one. The hero text on the marketing page passed AA. The settings page passed AA. The disabled state of the bulk-action dropdown inside the row-actions menu of a paginated table — which is what an operator actually looks at on hour seven of a night shift — failed at 2.9:1.

We treated WCAG 2.2 success criterion 1.4.3 as the floor, not the ceiling. The criterion is 4.5:1 for body text and 3:1 for large text and UI components. We scripted contrast checks across every role pair in both themes, then again across every component slot pairing, then again on every disabled-state and focus-ring variant. The first run caught 41 failing pairs. Most were inherited from the day-mode token sheet, never re-checked against the new dark surfaces.

Warning

The most common dark-mode contrast bug is the disabled state. A 40% opacity overlay on a dark surface almost always lands under 3:1. Test it with real tokens, not the design canvas.

The token math is easier in OKLCH than in hex because contrast tracks closely with the lightness coordinate. Once we knew --text-muted at L=40% on --surface-base at L=10% gave us about 5.4:1, we could move both up or down in five-point steps and predict the new ratio within half a point. Hex math cannot do that, and the design team had been doing it by hand for two years.

Perceived speed, measured from the field

The customer survey had also flagged "the dashboard feels slow at night". That was suspicious. Same software, same servers, different hour of the day. We pulled the Chrome User Experience Report field data for the dashboard's two busiest routes for the four weeks before launch and the four weeks after.

Largest Contentful Paint did not change meaningfully. It was already on the green side of the threshold. What did change was Interaction to Next Paint on the two big table views. Both routes had been re-rendering the entire row-grid on theme switch. Once the token system was in place, switching themes was a CSS custom-property swap on html[data-theme] — no React re-render, no chart redraw, no layout shift. Field-data INP at the 75th percentile moved out of the borderline zone and sat comfortably inside the "good" band for the rest of the cohort.

That is a lesson we keep re-learning. Perceived speed is rarely a backend story. The operator's brain decides whether your app is fast inside the first 200 milliseconds of an interaction, and a re-render storm during a theme change is enough to convince a night-shift user that the whole product is sluggish.

The twelve-week cohort

We launched on a Tuesday. The studio runs a tagged-ticket system in their support tool — every inbound ticket gets routed against a controlled vocabulary by the duty engineer.

Over the twelve weeks before launch, tickets tagged visibility, contrast, glare, eye-strain or dark-mode averaged eleven a week. Roughly two complaints a day, mostly from the same five enterprise accounts.

Over the twelve weeks after launch, that group of tags averaged 1.6 a week. The drop was concentrated in the first four weeks, then flat. The remaining tickets were almost all about a single specific surface — a third-party embedded calendar we had not yet replaced — and we eventually swapped that vendor out for an internal build.

Two things to flag honestly. First, the studio shipped a "reduce motion" preference and a global font-size lever in the same release, and we cannot fully isolate the contrast work from those. Second, ticket counts at the scale of a six-person studio are statistically noisy. We are not claiming a percentage. We are saying the dashboard stopped being a thing operators wrote in about.

Takeaway

OKLCH is not a fashion choice. It is the color space that lets you reason about contrast and brand consistency at the same time, in code, without a spreadsheet of hex values.

Two things we'd do differently

The first thing: build the contrast-audit script before the token sheet, not after. We refactored 41 pairs because we let the design canvas lead. Had the audit script been the source of truth from week one, the design system would have failed loud the moment a new role pair regressed, and the rework would have been an hour per failure instead of a sprint.

The second thing: ship a token-only preview app to the customer before the migration. The studio's enterprise seats had strong opinions on which surfaces felt "their brand" versus "Microsoft Teams at night". A standalone preview where night-shift leads could swap the brand primary themselves would have caught two arguments before they became invoiced rework.

The five-minute audit

If you run a SaaS product with a dark mode and you cannot tell me right now what contrast ratio your disabled-button text hits on your raised-surface background, you are not shipping a dark mode. You are shipping a Frankenstein. Open the dashboard, screenshot one busy view, drop it into any contrast checker, and read four surfaces: primary text, muted text, disabled text, and the focus ring. That is the audit. It takes five minutes and it tells you whether the rest of this work is worth your team's quarter.

When we rebuilt the dashboard for the Amsterdam studio, the hard part wasn't the color math. It was rewiring eighty components onto a token sheet without breaking a single existing customer's workflow during the four weeks of rollout. That is the kind of careful refactor we tend to do under our web work, and it almost always pays for itself in the support queue rather than in the design review.

Key takeaway

OKLCH lets you reason about contrast and brand consistency at the same time, in code, without a spreadsheet of hex values.

FAQ

Why OKLCH instead of HSL or hex?

OKLCH has uniform perceptual lightness across hues, so a primary blue and a primary green at the same step actually read at the same intensity. HSL and hex do not give you that, which makes contrast and brand math unreliable.

Does OKLCH have browser support in 2026?

Yes. Every evergreen browser has shipped it since late 2023. If your audience is on Chromium and Safari at fleet-issued versions, you do not need an sRGB fallback.

What contrast ratio should a dark dashboard target?

WCAG 2.2 AA: 4.5:1 for body text, 3:1 for large text and UI components like buttons and focus rings. Test every disabled-state pair as well, not just the marquee surfaces.

How long does a token rebuild take for a mid-sized SaaS dashboard?

For around eighty components and two themes, expect six to ten weeks if you have one designer and two engineers on it. Audit script first, then primitives, roles, slots, then rollout in flights.

case studyweb designdark modeaccessibilityuxarchitecture

Building something?

Start a project