Oct 01, 2024

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:

  • Composable, cascading themes - You should be able to set different themes at the page-level, section-level, and component-level, and have them cascade down until a different theme is applied.
  • Light & dark mode for all themes - Color themes shouldn’t be in addition to light and dark mode, every theme includes light and dark mode support.
  • Expressive - It should be easy to change opacity, use tints and shades, etc. within the context of your system. No having to go off system just because you need to add transparency.
  • Micromanagement-free - You should never even have to think about working with specific hues at the component level. In other words, adding a new theme shouldn’t mean touching every component.
  • Small set of properties - You should be able to worth with a small set of properties that you can further modify vs. maintaining an extensive list of semantic colors. Easier to work with, and easier to make new themes.
  • Good DX - It should feel good to work with. Flexible, straightforward, reads intuitively, forgiving, consistent, etc.

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

  • It’s dead simple. There’s only a few semantic colors, so it’s easy to work with at the component-level, and trivially easy to add new themes.

Cons of this approach

  • It’s dead simple. 3 colors, with no tints or shades works with the design direction of the site, but it’s too restrictive for general use.
  • Separate light and dark themes. Light and dark are independent themes, just like every color theme. In other words, you lose light/dark control once you switch to a color theme.
  • Incomplete color definitions. This system uses a pretty common approach of only assigning the values of a color to a variable, and using a complete color definition in components so that you can adjust opacity if need be. In practice I’ve found that decomposing like this introduces more cognitive friction than I’d like. “Can I use the variable here, or do I need to wrap it?” is a question I keep having to ask myself.

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

  • It’s still very simple.
  • It allows for very expressive per-page color and ultra-fast composition.
  • It’s easy to work with, and super low maintenance. You can have 1000 unique color compositions across pages without adding a single extra line of CSS.

Cons of this approach

  • There’s no light/dark mode support at all.
  • There’s still that slightly annoying “decomposed color” problem.
  • It’s still somewhat restrictive within individual themes. You can alter opacity, but that’s a poor proxy for actual tints and shades.

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

  • Super expressive, super flexible. In the example above I’m sticking to tint & shade presents, but nothing is stopping you from having fun with relative color while never going fully off-theme.
  • Straightforward, with a nice, tidy set of properties to work with. You get a full set of tints and shades for every color in every theme without having to manually define them.
  • You’re always working with fully formed colors.
  • No choosing between color themes and light/dark support. Every theme bakes it in.

Cons of this approach

  • These are very new properties. Relative color syntax in particular isn’t fully baked yet, with – at time of publish – no broad support for currentcolor or using a color defined with light-dark() as a source. Grease is always a bit forward looking (it’s purpose is to be the starting point for my next website, and I don’t start websites that often), but YMMV.
  • I’m sticking to the parts of RCS that are broadly available, so this approach is a little lacking on the relative part of relative color. There’s probably more simplicity to be had here when RCS is implemented in full and broadly available.

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”.

Read more notes