Chasing color

I’m a fan of side-quests; professional tangents that fuel your interest, sharpen your craft, and pay long-term dividends towards whatever your main quest is. I tend to have a few running at any point; some might last a few weeks, some off and on for years.

One of the latter is designing CSS color systems. It’s stuck around for a few reasons. Color is a powerful tool in the design toolkit, and designing a maintainable, flexible, malleable, adaptable color systems is hard (read; “fun to try and solve”). I also like like to keep things grounded in practical application, which means I’ve mostly pursued this side-quest in the context of websites I’m actively designing and building. In this case, that means my personal site, the website for Steady, and the latest iteration of my website starter, Grease. We’ll get to those in a moment. First, definitions.

Defining the ideal

What does an ideal CSS color system look like? My answer to this question has evolved along the way, but here’s what I think, today:

Adventure 1: “Can you theme grungy graphics?”

The design for the current iteration of this website was heavily influenced by a desire juxtapose modern minimalism against 90’s era grungy textures, and a desire to make color completely mutable.

For the graphics side of things, I used an approach of applying different SVG noise filters to text and CSS shapes. That meant I could grunge up anything and animate it without having to bake a bunch of different assets. And of course it meant everything could have color applied by CSS.

The color system was simple. Map your theme colors to a narrow set of semantic variables, and apply those variables to elements and components.

:root {
  /* colors */
  --color-white: 0deg 0% 85%;
  --color-black: 0deg 0% 10%;
  --color-yellow: 58deg 100% 50%;
  --color-gold: 51deg 100% 50%;

  /* semantic colors */
  --color-text: var(--color-black);
  --color-sheet: var(--color-white);
  --color-link: var(--color-text);
  --color-accent: var(--color-yellow);
}

a {
  color: hsl(var(--color-text));

  &:hover {
    text-decoration-color: hsl(var(--color-accent) / 75%);
  }
}

Light and dark mode are separate themes, along with a core set of designed themes and a “random” option, all keyed off a data attribute applied to the HTML element. Here’s the dark theme:

:root[theme="dark"] {
  --font-weight: 300;
  --color-text: var(--color-white);
  --color-sheet: var(--color-black);
  --color-link: var(--color-gold);
  --color-accent: var(--color-gold);
}

A little bit of javascript early in the page head works to apply themes, and prevent the dreaded FART (Flash of inAccurate coloR Theme):

<script>
  const theme = localStorage.getItem('theme') || 'system';
  document.documentElement.setAttribute('theme', theme);
  if (theme === "random") {
    document.documentElement.style.setProperty('--color-random-text', localStorage.getItem('color-text'));
    document.documentElement.style.setProperty('--color-random-sheet', localStorage.getItem('color-sheet'));      
  }
</script>

Pros of this approach

Cons of this approach

Adventure 2: “Can you make color composable?”

For steady.space, I wanted to see if I could address the “too restrictive” problem while keeping the good parts. I had a secondary goal here; I wanted to make standing up and designing new pages very fast and very easy.

So I took the core of the last approach, and broke down the “one theme per page” model. Now, every page would be composed of “micro-themes” that cascade down until a new theme is applied. To meet my secondary goal, I married it to a set of color utilities that let me quickly and easily compose expressive color on every page without touching CSS.

Themes are set up pretty much the same as before:

.theme-dark {
  --color-text: var(--color-asphalt-0);
  --color-accent: var(--color-ultramarine-400);
  --color-sheet: var(--color-asphalt-900);
  --color-pattern: var(--color-asphalt-800);
}

.theme-grape-aqua {
  --color-text: var(--color-grape-600);
  --color-accent: var(--color-grape-600);
  --color-pattern: var(--color-aqua-400);
  --color-sheet: var(--color-aqua-300);
}

But instead of a single theme applied to the HTML element, you use them like so:

<main>
  <section class="theme-dark">
    <h2 class="color --text --use-accent">alpha</h2>
    <p>bravo</p>
  </section>
  <section class="theme-light">
    <ul>
      <li class="card theme-ultra">charlie</li>
      <li class="card theme-rhubarb">delta</li>
    </ul>
  </section>
</main>

If you’re wondering what’s happening with that h2, it’s a little theme re-mapper:

.color {
  &.--text { --color-text: var(--property); }
  &.--sheet { --color-sheet: var(--property); }
  &.--accent { --color-accent: var(--property); }
  &.--pattern { --color-pattern: var(--property); }

  &.--use-sheet { --property: var(--color-sheet); }
  &.--use-text { --property: var(--color-text); }
  &.--use-accent { --property: var(--color-accent); }
  &.--use-pattern { --property: var(--color-pattern); }
}

It gives you one more level of flexibility without having to dip into CSS, and splitting it up into two properties means I didn’t have to make a utility class for every possible combination.

Pros of this approach

Cons of this approach

Adventure 3: “Can new CSS make it better?”

When I started working on a new version of Grease, I knew one of things I wanted to focus on was a solid, modern, broadly useful color system. And more specifically, I was keen to see if new tools like light-dark() and relative color syntax could make it better. Spolier: yes.

Once again, I wanted to keep the good parts, and whittle away at the cons. The changes start at the top with some new primitives; source colors, and tint & shade steps:

:root {
  color-scheme: light dark;

  /* source colors */
  --neutral: oklch(80% 0.01 210);
  --primary: oklch(30% 0.16 210);
  --secondary: oklch(70% 0.11 20);

  /* tints & shades */
  --0: 100% 0 h;
  --50: 99% calc(c/16) h;
  --100: 94% calc(c/4) h;
  --200: 88% calc(c/2) h;
  --300: 70% c h;
  --300: 70% c h;
  --400: 60% c h;
  --500: 50% c h;
  --600: 40% c h;
  --700: 30% c h;
  --800: 20% calc(c/1.5) h;
  --900: 15% calc(c/2) h;
}

This approach makes it very straightforward to develop a complete, consistent color palette without having to maintain an exhaustive list of properties. A perceptually uniform color space like OKLCH is crucial to making generated color systems like this work well.

Those values get baked into preset colors, using relative color syntax to combine source color with tints & shades, and light-dark() to bake in theme-level light/dark mode support.

  /* preset colors */
  --color-text: light-dark(
    var(--primary),
    white
  );
  --color-bg: light-dark(
    white,
    oklch(from var(--primary) var(--800))
  );
  --color-border: light-dark(
    var(--neutral),
    oklch(from var(--primary) var(--600))
  );
  --color-subtle: light-dark(
    oklch(from var(--neutral) var(--50)),
    oklch(from var(--primary) var(--900))
  );
  --color-accent: var(--secondary);
  --color-shadow: black;

Elements and components can and should use the preset colors as much as possible, but it’s easy to carve out on-theme exceptions. Just drop back down to the primitives:

a:hover {
  color: light-dark(
    oklch(from var(--secondary) var(--600)), 
    oklch(from var(--secondary) var(--200))
  );
}

You create additional themes by redefining your source colors and presets:

.high-contrast {
  --neutral: oklch(30% 0 210);
  --primary: oklch(30% 0.36 210);
  --secondary: oklch(85% 0.36 20);
  --color-text: light-dark(black, white);
  --color-bg: light-dark(white, black);
  --color-border: light-dark(black, white);
  --color-accent: var(--secondary);
  --color-subtle: light-dark(
    oklch(from var(--neutral) var(--100) / 50%),
    oklch(from var(--neutral) var(--800))
  );
}

Composition mostly works the same. Themes are automatically light/dark aware, but thanks to light-dark(), a one-line utility lets you force one mode or the other:

<main>
  <section class="theme-primary">
    <h2>alpha</h2>
    <p>bravo</p>
  </section>
  <section class="theme-secondary dark">
    <ul>
      <li class="card theme-primary light">charlie</li>
      <li class="card theme-primary light">delta</li>
    </ul>
  </section>
</main>

Pros of this approach

Cons of this approach

The quest continues

I’m happy with the progress I’ve made here, but as you can see by the fact that I’m still listing cons, I’m not 100% satisfied. I just can’t help but feel that I haven’t quite cracked it yet. So onto the back burner it goes where it’ll sit and marinate. And once I’ve had time to live with this approach, and relative color syntax has time to mature, I’ll try again to see if I can finally call this side-quest “done”.