A Structured Approach to Custom Properties

Yesterday, I announced the release of the Theme Machine, a tool for generating custom palettes as CSS custom properties. I hope this tool proves useful for many developers, but it’s really only the first step in managing color in CSS. I think it’s worth stepping back and looking at the bigger picture of custom property management in general.

If you’re anything like me, you’ve probably tried a handful of different approaches to organizing your custom properties. Most approaches I’ve used have been adequate, but I rarely feel completely satisfied with them. I spent far too long flailing about trying to find what worked. I should have looked sooner to the realm of design tokens. I think some common standards when building a design system are useful when applied directly to CSS custom properties.

I’d like to lay out my current approach. In many ways, these thoughts are still a work in progress, but I feel good enough about them to teach them, and at the very least invite feedback. You’ll notice my thoughts here start concrete, but things will start to get a little fuzzier toward the end of this post.

Three tiers

Every custom property you define should fall in one of three categories, and you should always be aware when defining or using a custom property which category it belongs to. I’m going to refer to these three types of property as primitive, semantic, and module properties. If this sounds familiar from the world of design tokens, that’s because it’s nearly the exact same concept.

  1. Primitive properties hold raw, generic values. A design begins with initial decisions about what colors and fonts and other elements “fit” the design. These values should be stored in primitive properties.
  2. Semantic properties supply context and meaning. The names of these properties imply where they should be used throughout the stylesheet. These are often built out of primitive properties, but supply additional direction as to their use.
  3. Module properties are used only within the confines of a particular module or component on the page. If a dropdown menu has its own drop shadow in a custom property, for instance, it may be defined within the scope of that module rather than globally on the page as a whole.

I’ll explain each of these in more depth and provide some practical examples to show what they typically look like.

Primitive properties

Primitive custom properties are global and static. Chief among these are your color palettes. They will typically also include font stacks. There may be a place for a few other items in this tier, but most things beyond these will typically belong in other tiers.

:root {
  --blue-1: oklch(98% 0.008 250deg);
  --blue-2: oklch(93% 0.028 250deg);
  --blue-3: oklch(80% 0.045 250deg);
  --blue-4: oklch(66% 0.056 250deg);
  --blue-5: oklch(58% 0.059 250deg);
  --blue-6: oklch(49% 0.053 250deg);
  --blue-7: oklch(35% 0.042 250deg);
  --blue-8: oklch(20% 0.025 250deg);
  --blue-9: oklch(10% 0.014 250deg);
  /* repeat this pattern as needed for gray, green, etc. */

  --font-serif: 'Georgia Pro', Georgia, serif;
  --font-sans-serif: 'Source Sans 3', sans-serif;
  --font-monospace: 'Fira Mono', Consolas, monospace;
}

Here’s the key rule for this first tier: you should not be using them throughout your stylesheet. You should instead be using them to build custom properties on the next tier, the semantic properties. Let’s look at the other types of properties, then I’ll come back to this rule and why I try to hold myself to it.

Semantic properties

Semantic properties are the real building blocks of your design. While the primitive properties define all the values you’ll need within the scope of your design, each semantic property maps one of those primitive values to a practical intent.

In this tier, you can define a set of background colors, text colors, common margins and border radius sizes. Many of these properties will be derived from primitive properties, looking something like this:

:root {
  --background-1: var(--gray-2);
  --background-2: var(--gray-3);
  --background-3: var(--gray-4);
  --text-1: var(--gray-9);
  --text-2: var(--gray-8);

  --font-display: var(--font-serif);
  --font-body: var(--font-sans-serif);
  --font-code: var(--font-monospace);
}

Some properties on this tier might be defined with explicit values. You could potentially define primitives for common lengths, like --length-sm: 2px and --length-md: 4px then use these to define semantic border radius properties, but in many cases this is likely overkill, and it’s more practical to simply apply these values directly to the semantic properties like this:

:root {
  --radius-sm: 2px;
  --radius-md: 4px;
  --radius-lg: 8px;
}

Other useful semantic properties include anything and everything you want to keep consistent throughout your design. This often includes:

  • font sizes
  • font weights (especially if you have a variable font and want to specify uncommon weights like 300 or 900)
  • line/border widths
  • spacer sizes for padding and margin
  • border radius
  • drop shadows
  • easing functions

Theoretically, a robust set of semantic property names could be reused over and over again across projects with few modifications. Only the values assigned to those properties would change across different designs.

The potential to be dynamic

Like primitive properties, semantic properties are global to the page. However, some of them might be dynamic. This is especially common if you want to allow the user to switch themes or you want to support both light and dark modes. The primitive --gray-2 always means the same gray, but it’s only a background color in light mode. In dark mode, it might be a text color. You can make this distinction when you define your semantic properties.

:root {
  color-scheme: light;
  --background-1: var(--gray-2);
  --background-2: var(--gray-3);
  --background-3: var(--gray-4);
  --text-1: var(--gray-9);
  --text-2: var(--gray-8);
}
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --background-1: var(--gray-9);
    --background-2: var(--gray-8);
    --background-3: var(--gray-7);
    --text-1: var(--gray-1);
    --text-2: var(--gray-2);
  }
}

Note that this is even simpler with the light-dark(), though browser support for this function is still pretty fresh:

:root {
  --background-1: light-dark(var(--gray-2), var(--gray-9));
  --background-2: light-dark(var(--gray-3), var(--gray-8));
  /* etc */
}

If you use Lightning CSS, you can use light-dark() now, as they found a creative way to transpile it to more broadly supported syntax — though be sure to test this well if you allow the user to dynamically switch themes.

Reduced cognitive load

As well as enabling theming support, semantic properties streamline the mental task of everyday coding. You might have eight or ten or 16 different grays and just as many blues, but in your semantic properties, you only have a small handful of background colors.

When you need to set the background color of a module, and you use primitives directly, you’ll find yourself thinking, “Did I use --gray-4 or --gray-3 for the background of that other similar module?” But with a much smaller subset of semantic background properties to choose from, you’re more likely to recall that --background-2 is the one you’ve been reaching for repeatedly.

When you rely on semantic properties instead of your primitives, you don’t have to keep making the same decisions over and over, and you don’t have to continually refer back to other code to remember the decisions you’ve already made.

Module properties

The last tier is for properties that are not global, but are instead scoped locally to a single module/component on the page.

If you need to calculate multiple properties based on a single value, or you need to change the size of part of a component responsively, this is a way to do it.

For example, in the current design on this site, I use this sort of property to manage the circle clip path of the banner image at the top of every post. It looks like this:

.banner-image {
  --radius: min(35vw, 750px);
  --offset: 10vw;
  --circle: circle(var(--radius) at var(--radius) var(--offset));
  position: relative;
  z-index: 1;
  clip-path: var(--circle);
  shape-outside: var(--circle);
  float: right;
  width: calc(var(--radius) + var(--offset));
  shape-margin: 0.5rem;
}

These variables are all defined and used locally only within this particular module. Because they’re defined right here, I don’t really need to worry about variable name collision from other modules higher in the DOM. (Though you do need to avoid overriding semantic properties that might be referenced by any nested child modules.)

This also makes aspects of responsive design much simpler. Use a media query or container query to adjust a module custom property:

@media (min-width: 720px) {
  .logo {
    --size: 70px;
  }
}

This is just so much less cognitive load than re-defining a bunch of properties directly. Responsive design often involves a lot of looking up and down the stylesheet to see which properties are specified at which breakpoints and trying to keep mental track of which override in various circumstances. It’s not always practical to relegate this logic purely to a custom property, but it’s absolutely worth doing when you can.

Trying not to use primitive properties directly

I mentioned early the general rule that I avoid assigning primitive properties directly in my styles. Rather, I use them only to build out other custom properties.

It’s often very tempting to break this rule. Maybe you’ll really want a component on the page to have a border that’s colored --blue-6. If I’m totally honest, I break it sometimes, too, but it always gives me pause. It will often make some trouble down the road, and I weigh whether I want to deal with that. I ask myself a few questions:

  • Is this really a one-off use of this value?
  • Why don’t any of my existing semantic properties meet this need?
  • Is there a general semantic meaning I’m reaching for, and should I codify it in a semantic property if one doesn’t already exist?
  • Will this complicate any theme switching on my site?

Ideally, there will be a semantic meaning I can create instead. I’ll add a new semantic variable to the root of the page and rely on that.

At the same time, I’m also cautious about populating the page with hundreds of semantic properties that are only ever used once in the stylesheet.

My hope is that I will, over time, develop a robust system of common semantic properties that will greatly reduce the need to ever reach for these one-offs. I have further thoughts on this already, and hopefully I’ll be able to gather them into another post in the future.

Thoughts on naming conventions

Ideally, I’d like to propose a clean naming convention that always makes clear which property is of which type, but I’ve never really seen or come up with something I totally love.

It’s unfortunate that custom property names are severely limited. You can use alphanumerics, hyphens, and underscores. The only other characters available non-ASCII unicode. As fun as the property --🎩-color is, it’s totally impractical for real use. Characters that are easy to type like dollar signs, asterisks, periods, and parenthesis only work if you escape them with a backslash, and I just find that ugly.

Module properties might be the easiest to identify. I think a leading underscore is a reasonable convention, akin to private variables in many programming languages. You can see at a glance that color: var(--_color); references a local, module-specific property. I’ve used this convention occasionally, but not always consistently.

As far as differentiating primitive vs. semantic properties, nearly every idea leads down the realm of BEM-like syntax, with a clutter of double hyphens or a mix of hyphens and underscores, but I don’t think anyone was ever really excited about that in the past and I would really love to avoid going down that sort of path again.

One thing that might be practical is the use of uppercase characters. Perhaps an all-lowercase --size-sm could indicate a primitive property, while a capitalized --Spacer-md or even --SpacerMd indicates a semantic one. Or maybe every primitive could be prefixed with p-.

I don’t know. I’m not sure I love camel-casing my custom properties, or the distinction needs to be so explicitly described in the syntax. I guess I have a thing for code that doesn’t look ugly to my eyes.

I’d love to hear your thoughts on all this.

Hopefully it’s helpful to you to draw these distinctions between different types of custom property. When I survey existing pattern libraries, I see a lot of this kind of pattern, but I haven’t seen it really being explicitly discussed anywhere publicly.

Loading interactions…

Recent Posts

See all posts