Computed Design Tokens

Published See discussion on Twitter

Alternative titles: Dependentdesign tokens, computed theme values

For the past few weeks I've been working on updating the theme within my @ds-pack/components package, specifically around the colors that I'm using within the theme and the ones that map to "functional roles" (e.g. primary, secondary, ... etc). Through this work I've been exploring a few different patterns that I wanted to share more widely.

Before diving into the two things I've been tinkering with I want to step back and define the problem space a bit, specifically with this work I've been thinking about how to enable consumers of the above components package to theme the system without needing to re-construct the whole theme object that the components use.

In a traditional theme, this customization might be fairly basic by using object spread to override some values:

1let newTheme = {
2 ...baseTheme,
3 override: "value",
4};
1let newTheme = {
2 ...baseTheme,
3 override: "value",
4};

This works really well for flat theme shapes (which might be worthy of another blog post in itself), but for nested themes, this soon gets out of hand:

1let newTheme = {
2 ...baseTheme,
3 colors: {
4 ...baseTheme.colors,
5 buttons: {
6 ...baseTheme.colors.buttons,
7 primary: "new value",
8 },
9 },
10};
1let newTheme = {
2 ...baseTheme,
3 colors: {
4 ...baseTheme.colors,
5 buttons: {
6 ...baseTheme.colors.buttons,
7 primary: "new value",
8 },
9 },
10};

Another complication that arises when customizing a theme is dependent theme values, let's say that the theme shape also contains some styles for different variants of a component (e.g. styled-system's variant), and those styles use the same values from other parts of theme:

1let baseTheme = {
2 colors: {
3 primary: "mediumspringgreen",
4 },
5 buttons: {
6 primary: {
7 color: "mediumspringgreen",
8 },
9 },
10};
1let baseTheme = {
2 colors: {
3 primary: "mediumspringgreen",
4 },
5 buttons: {
6 primary: {
7 color: "mediumspringgreen",
8 },
9 },
10};

If the system defines the theme like above, and a consumer wants to change the primary color to cornflowerblue, the consumer may not know that they need to change the buttons.primary.color value as well.

These two issues:

  1. Less than ideal ergonomics for overriding themes
  2. Dependent theme values don't react to overrides

make theme overriding a particular indersting challenge.

So let's finally dive into some of the ideas I've been working on, specifically two concepts:

Theme Getters

The first idea that I started workshopping was to use a getter to define values within theme that can reference other values within the theme.

I didn't mention above, but a common workaround I've seen for the second problem noted is defining the theme primitives outside of the scope of the theme object, and referencing those later:

1let colors = {
2 primary: "mediumspringgreen",
3};
4
5export default {
6 colors,
7 buttons: {
8 primary: {
9 color: colors.primary,
10 },
11 },
12};
1let colors = {
2 primary: "mediumspringgreen",
3};
4
5export default {
6 colors,
7 buttons: {
8 primary: {
9 color: colors.primary,
10 },
11 },
12};

However this only solves the referencing issue for the file in which the theme is defined (frequently that is within the source package for the system). With getters however, you move that derivation time to runtime rather than module load time:

1export default {
2 colors: {
3 primary: "mediumspringgreen",
4 },
5 get buttons() {
6 return {
7 primary: {
8 color: this.colors.primary,
9 },
10 };
11 },
12};
1export default {
2 colors: {
3 primary: "mediumspringgreen",
4 },
5 get buttons() {
6 return {
7 primary: {
8 color: this.colors.primary,
9 },
10 };
11 },
12};

Now, the button primary color inherits the primary color specified on the theme, allowing us to override only that value and have the button styles inherit those changes:

1let newTheme = {
2 // buttons styles don't need to be modified at all!
3 ...baseTheme,
4 colors: {
5 ...baseTheme.colors,
6 primary: "cornflowerblue",
7 },
8};
9
10// newTheme.buttons.primary.color === 'cornflowerblue'
1let newTheme = {
2 // buttons styles don't need to be modified at all!
3 ...baseTheme,
4 colors: {
5 ...baseTheme.colors,
6 primary: "cornflowerblue",
7 },
8};
9
10// newTheme.buttons.primary.color === 'cornflowerblue'

"Token References"

The second idea I've been working on is around using token references[1], popularized by Stitches and used by System Props.

Essentially using the same syntax that you might use on a traditional Box component:

1<Box color="$colors.primary" />;
1<Box color="$colors.primary" />;

within the theme shape itself:

1let theme = themer({
2 colors: {
3 primary: "mediumspringgreen",
4 },
5 buttons: {
6 primary: {
7 color: "$colors.primary",
8 },
9 },
10});
11
12// theme ===
13// {
14// colors: {
15// primary: 'mediumspringgreen',
16// },
17// buttons: {
18// primary: {
19// color: 'mediumspringgreen',
20// },
21// },
22// }
1let theme = themer({
2 colors: {
3 primary: "mediumspringgreen",
4 },
5 buttons: {
6 primary: {
7 color: "$colors.primary",
8 },
9 },
10});
11
12// theme ===
13// {
14// colors: {
15// primary: 'mediumspringgreen',
16// },
17// buttons: {
18// primary: {
19// color: 'mediumspringgreen',
20// },
21// },
22// }

With this method, we've moved the derivation from the reference of the value with the getter method to the callsite of this themer function (which has a few trade-offs depending on how dynamic your theme needs to be).

To experiment with this more deeply, I built a small package that you can try out: @ds-pack/themer.

I'd love to hear if you've found interesting solutions to these problems, feel free to reach out on twitter or via email.


Footnotes:

[1] - There is probably a better term for this, if you know of them,let me know!