Ink, paper, and one hue that does both

A site redesign that got more playful with color — and stopped maintaining separate light and dark palettes. One OKLCH hue, ink and paper mixed from it, first paint already sure which is which.

With this site redesign I wanted to be more playful with color. Try formats that were not hex and HSL. See how far CSS custom properties and a little JavaScript could take the automation — enough to turn a dial, not enough to restyle the page by hand every time the mood changed. The catch was light and dark: two design systems, two stylesheets, two palettes I swore I would keep in sync. The first theme is easy. The second one drifts the moment you change a border, a kicker, or a wash.

So I stopped thinking in palettes. Think in ink and paper.

Pick one hue. Mix it toward black for a darkened version. Mix it toward white for a lightened version. Light mode uses the dark mix as ink and the light mix as paper. Dark mode does the reverse. Same tokens. Same stylesheet. The only flip is which mix is which.

:root {
  --primary-color: oklch(0.85 0.25 180);
  --darken: color-mix(in oklch, var(--primary-color), black 60%);
  --lighten: color-mix(in oklch, var(--primary-color), white 75%);
  --text-color: var(--darken);
  --bg-color: var(--lighten);
}

[data-theme="dark"] {
  --text-color: var(--lighten);
  --bg-color: var(--darken);
}

[data-theme="light"] {
  --text-color: var(--darken);
  --bg-color: var(--lighten);
}

color-mix gives me ink and paper from the same source. Custom properties point text and background at opposite ends depending on mode. Components never hardcode #0a7 or #111. They read --text-color, --bg-color, --link-color. When the hue moves, every dependent token moves with it. When the mode flips, contrast flips without a second design system to maintain.

Split complements ride the same idea. Rotate the hue around the wheel and you get companions for kickers and accents without inventing a second brand color by hand:

:root {
  --split-complement1: oklch(0.85 0.25 calc(180 + 150));
  --split-complement2: oklch(0.85 0.25 calc(180 - 150));
  --color-kicker: color-mix(in oklab, var(--split-complement1), black 30%);
}

Once ink, paper, and accents are all relatives of one hue, light and dark stop being two products. They are one recipe with the roles swapped.

Why OKLCH

I use OKLCH because lightness stays perceptually even as hue moves. An old HSL teal and an HSL yellow at the same “lightness” do not feel equally bright. OKLCH mostly does.

That matters here because a visitor can scrub the whole spectrum with a slider. Body text, links, and kickers still have to remain readable at every stop. If the color space lies about brightness, the ink-and-paper trick falls apart the moment someone leaves the teal you tuned by eye.

OKLCH is also a good home for color-mix. Mixing toward black and white in a perceptual space keeps the darkened and lightened versions feeling like the same color family, not like two unrelated greys that happen to share a name. The mental model stays honest: one hue, two roles, readable either way.

Changing the hue in JavaScript

CSS holds the structure. JavaScript turns the dial.

When the hue slider moves — or when the idle cycle advances — the script rewrites the primary and both split complements on the document root. Lightness stays put. Chroma drops a little in dark mode so saturated hues do not go neon-loud on a dark wash. Everything else in the stylesheet is already listening to those custom properties, so the page reharmonizes without a component-by-component restyle.

function updateColors(hue, theme) {
  const rootStyle = document.documentElement.style;
  const isLight = theme === 'light';
  const chroma = isLight ? 0.25 : 0.2;
  const lightness = 0.85;

  rootStyle.setProperty('--primary-color', `oklch(${lightness} ${chroma} ${hue})`);
  rootStyle.setProperty(
    '--split-complement1',
    `oklch(${lightness} ${chroma} ${(hue + 150) % 360})`
  );
  rootStyle.setProperty(
    '--split-complement2',
    `oklch(${lightness} ${chroma} ${(hue + 210) % 360})`
  );

  document
    .querySelector('meta[name="theme-color"]')
    ?.setAttribute('content', `oklch(${lightness} ${chroma} ${hue})`);
}

JS owns the hue and its companions. CSS owns the mixes and the roles. Updating meta[name="theme-color"] at the same time keeps supporting mobile browsers from freezing their chrome on whatever default the HTML started with.

The controls stay boring on purpose. A slider writes a number from 0 to 360. Play/pause can idle-cycle that number with requestAnimationFrame. None of that invents color. It only edits the same tokens the rest of the CSS already reads.

function handleSliderInput(event) {
  const hue = parseFloat(event.target.value);
  if (Number.isNaN(hue)) return;

  state.currentHue = hue;
  updateColors(hue, state.currentTheme);
  updatePreviewColor(hue);
  persistHue();

  if (state.isCycling) stopColorCycle();
}

Downstream components stay dumb. Links use --link-color. Kickers use --color-kicker. Borders and code chips mix against --text-color. No component reaches into localStorage. No component asks “are we in dark mode?” if a token already answered.

.kicker {
  color: var(--color-kicker);
}

code {
  color: color-mix(in oklch, var(--split-complement2) 55%, var(--text-color));
  background: color-mix(in oklch, var(--text-color) 8%, transparent);
  border: 1px solid color-mix(in oklch, var(--text-color) 14%, transparent);
}

Those rules never ask about light or dark. They read tokens.

Theme swapping, without the flash

Mode is a separate axis from hue. Light, Dark, and Auto do not invent a third palette. They decide which mix is ink and which mix is paper.

Auto watches prefers-color-scheme and writes either light or dark into data-theme. If the operating system later flips while the user is still on Auto, a media-query listener re-applies the theme. An explicit Light or Dark choice wins.

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

function setColorMode(mode) {
  state.currentMode = mode;

  if (mode === 'auto') {
    state.currentTheme = mediaQuery.matches ? 'dark' : 'light';
    localStorage.removeItem('explicitMode');
  } else {
    state.currentTheme = mode;
    localStorage.setItem('explicitMode', mode);
  }

  applyTheme();
}

function handleSystemPreferenceChange(event) {
  if (state.currentMode === 'auto') {
    state.currentTheme = event.matches ? 'dark' : 'light';
    applyTheme();
  }
}

mediaQuery.addEventListener('change', handleSystemPreferenceChange);

The background recipe stays the same shape in both modes: a soft radial, a noise tile, and a base wash mixed from the primary. Only the ink and paper roles flip.

[data-theme="dark"] {
  --text-color: var(--lighten);
  --bg-color: var(--darken);
  --link-color: var(--primary-color);
  --background:
    radial-gradient(
      circle,
      color-mix(in oklch, var(--primary-color), black 10%) 0%,
      transparent
    ),
    var(--bg-noise) repeat,
    color-mix(in oklch, var(--primary-color), black 25%);
}

[data-theme="light"] {
  --text-color: var(--darken);
  --bg-color: var(--lighten);
  --link-color: var(--primary-color);
  --border-color: var(--text-color);
  --background:
    radial-gradient(circle, var(--primary-color) 0%, transparent),
    var(--bg-noise) repeat,
    color-mix(in oklch, var(--primary-color), black 15%);
}

html {
  color: var(--text-color);
  background: var(--primary-color);
  transition: color var(--transition-fast), background var(--transition-fast);
}

body {
  color: var(--text-color);
  background: var(--background);
}

All of that is useless if the first frame shows the wrong mode. Theme preference lives in localStorage, so a tiny blocking script in <head> reads that state before CSS has a chance to paint the wrong thing.

<html lang="en" class="no-fouc focus-in">
<head>
  <script>
    (function () {
      const root = document.documentElement;
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const savedExplicitMode = localStorage.getItem('explicitMode');
      const effectiveMode = savedExplicitMode || (prefersDark ? 'dark' : 'light');

      root.setAttribute('data-theme', effectiveMode);

      let savedHue = localStorage.getItem('bgHue');
      if (!savedHue) {
        savedHue = Math.floor(Math.random() * 360);
        localStorage.setItem('bgHue', savedHue);
      } else {
        savedHue = parseInt(savedHue, 10);
      }

      const isLight = effectiveMode === 'light';
      const chroma = isLight ? 0.25 : 0.2;
      const lightness = 0.85;

      root.style.setProperty('--primary-color', `oklch(${lightness} ${chroma} ${savedHue})`);
      root.style.setProperty(
        '--split-complement1',
        `oklch(${lightness} ${chroma} ${(savedHue + 150) % 360})`
      );
      root.style.setProperty(
        '--split-complement2',
        `oklch(${lightness} ${chroma} ${(savedHue + 210) % 360})`
      );
    })();
  </script>

  <style>
    html.no-fouc {
      visibility: hidden;
    }
  </style>
</head>

Until that script finishes, html carries no-fouc and stays hidden. One requestAnimationFrame later the class comes off and the page appears already in the right mode, already wearing the right hue.

requestAnimationFrame(() => {
  document.documentElement.classList.remove('no-fouc');
});

Without that bootstrap you get the classic FOUC: a flash of the wrong mode or the wrong hue. On a site whose personality is color, that flash feels broken, not unfinished.

The gotchas that made it feel smooth

The model above is the easy part to explain. The smoother feel came from lessons that only showed up after the first version almost worked.

Hue and mode are separate memories. Early on I stuffed too much into one preference. Picking a teal would quietly lock someone into dark, or flipping light would reset the hue they had just tuned. Now hue lives in bgHue and an explicit light/dark choice lives in explicitMode. No explicit mode means Auto.

Dark mode needs less chroma, not just swapped ink. The same 0.25 chroma that feels alive on paper goes neon-loud on a dark wash. Dropping to 0.2 in dark is a small asymmetry, and the one that keeps the personality without the headache.

A cycling hue is a bad roommate for a slider. If the spectrum keeps drifting while you scrub, you are fighting the control. Touching the slider stops the cycle. Scrubbing is an explicit takeover.

Background tabs should not keep painting. The cycle advances by elapsed time inside requestAnimationFrame, so a slow frame does not stall the spectrum — but a hidden tab should not keep writing colors into a page nobody can see. On visibilitychange, persist the hue, cancel the frame loop, and resume from where you left off when the tab comes back. pagehide forces one last persist so a close or navigation does not lose the stop you landed on. Persist itself is throttled during idle cycling so localStorage is not hammered sixty times a second.

None of that changes the bet. It keeps the bet from feeling flaky once someone starts scrubbing the slider, flipping the OS theme, and leaving the tab open overnight.

I wanted light and dark without two design systems, and a personal hue without painting every widget by hand. Ink and paper from one OKLCH hue got me there. A little JavaScript turns the dial. The bootstrap makes sure the first paint already knows the answer. The rest was learning where that model almost lies to you.