Manage design tokens with TypeScript and styled-components
Design systems are a hot topic the last couple of months. More and more companies are publishing their design systems, and better tools emerge to build those design systems. Be sure to checkout Lee Robinson's excellent article, and Storybook's brand new course on the subject. This article isn't about design systems,but about design tokens and how to manage them in your app.
According to the Lightning Design System by salesforce:
Design tokens are the visual design atoms of the design system --- specifically, they are named entities that store visual design attributes. We use them in place of hard-coded values (such as hex values for color or pixel values for spacing) in order to maintain a scalable and consistent visual system for UI development.
Basically things like colors, fonts, sizes, etc...
Why should I use design tokens?
Design tokens are a part of every design system. But you don't neccecaraly have to have a design system to work with them. As a matter of fact, you could utilize design tokens today, or use it as a stepping stone towards a 'bigger' design system (my company did).
Design tokens offer a few benefits. Let me name a couple of important ones:
Consistency
By defining tokens, everybody on the team is on the same page. Instead of hex-colors flying around all over the place, all colors are named and put in a central location for everyone to see.
Limitation
Yes, limitation can be a good thing! When a was still working as a music producer, I sometimes had to record a keyboard part for a track. At first, I sat for hours trying to find that perfect sound, twisting all kinds of knobs, only to find out later that strings were probably a way better choice. Later, I discovered that presets would save me a lot of hassle. So in other words, limit your options, and focus your creativity on what's important; the product you're building.
(DX / type checks)
When you choose to store your tokens in plain javascript strings / arrays and objects, and add TypeScript types on top of that, you'll get...
- Autocompletion - no more looking up how that particular variable was called
- type-checks - If you've made a typo, TypeScript will let you know
Let's build something!
I should mention that the goal of this article is not to provide you with a full-featured production-ready tool. Every company and project has it's own needs, so instead my advice would be to watch, learn and copy / alter the pieces you find useful. Maybe I will create a little library out of this article in the future... who knows ;)
For the tooling we're about to build I assume that you are familiar with styled-components and that you understand some basic TypeScript. I will be covering some advanced TypeScript patterns, but I will do my best to explain what's going on.
The idea is that we want to obtain access to our tokens in two ways:
- Directly inside a styled-component
- With the help of a hook inside a 'normal' component
To get some sense of what we're going to build, a little sneak-preview:
import { theme, useTheme } from "@your-company/theme";
// inside a styled-component
const Example = styled.div`
color: ${theme.color("primary", 5)};
margin-top: ${theme.space(2)};
`;
// inside a normal component
function Example() {
const theme = useTheme();
return (
<div
style={{
color: theme.color("primary", 5),
marginTop: theme.space(2),
}}
>
We're accessing tokens inside a component :)!
</div>
);
}
Alright, let's get started!
Step 1: Define your tokens
In order to keep this article easy to understand, we will focus on two token types:
- colors (primary / secondary)
- spaces (margins / padding / etc )
The idea of spacing and the color types comes from Refactoring UI; Great book to level-up your design skills as a programmer!
// theme.ts
const theme = {
colors: {
primary: [
"hsl(205, 79%, 97%)",
"hsl(205, 97%, 85%)",
"hsl(205 ,84%, 74%)",
"hsl(205 ,74%, 65%)",
"hsl(205, 65%, 55%)",
"hsl(205, 67%, 45%)",
"hsl(205, 76%, 39%)",
"hsl(205, 82%, 33%)",
"hsl(205, 87%, 29%)",
"hsl(205, 100%, 21%)",
],
secondary: [
"hsl(171, 82%, 96%)",
"hsl(172, 97%, 88%)",
"hsl(174, 96%, 78%)",
"hsl(176, 87%, 67%)",
"hsl(178, 78%, 57%)",
"hsl(180, 77%, 47%)",
"hsl(182, 85%, 39%)",
"hsl(184, 90%, 34%)",
"hsl(186, 91%, 29%)",
"hsl(188, 91%, 23%)",
],
},
space: [4, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256],
};
export default theme;
Now that we have a basic structure of our theme (tokens), let's make a few types from it (we will use them in a second)
// theme.ts
const theme = {
/* tokens */
};
// entire structure of our theme-object
export type Theme = typeof theme;
// props that later will be injected by styled-components
export type ThemeProps = { theme?: Theme };
// 'primary' | 'secondary'
export type ColorType = keyof Theme["colors"];
// 9 possible shades of a ColorType
export type ColorIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
// 12 possible spaces
export type SpaceIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
export default theme;
Step 2: create getters
We could stop here, right? I mean, we could just use our defined theme directly inside a styled-component. Like so:
const Div = styled.div`
color: ${props => props.theme.colors.primary[9]};
`;
Well, yes, you could. But I like to use a little 'getter' function, and for two reasons:
- By using a 'getter' function, we get to add additional behavior. In our case, a fallback to a 'default' theme, when
<ThemeProvider />
wasn't used. - Developer experience - I think we can make it a little bit more developer friendly. Sure, it doesn't matter for a few styled-components, but after a while you'd wished you had a 'getter' ;)
Let's create those getters!
// getters.ts
import defaultTheme, {
ColorType,
ColorIndex,
ThemeProps,
SpaceIndex,
} from "./theme";
// utility function to fallback to `defaultTheme` when necessary
function getTheme(props: ThemeProps) {
return props.theme && props.theme.colors ? props.theme : defaultTheme;
}
// getColor("primary", 9, { theme }) === "hsl(205, 100%, 21%)"
export function getColor(
type: ColorType,
index: ColorIndex,
props: ThemeProps
) {
return getTheme(props).colors[type][index];
}
// getSpace(2, { theme }) === "12px"
export function getSpace(index: SpaceIndex, props: ThemeProps) {
return getTheme(props).space[index] + "px";
}
Step 3: create selectors
Right now, we already could utilize those getters:
const Div = styled.div`
color: ${props => getColor("primary", 9, props)};
`;
But I think we can do better:
const Div = styled.div`
color: ${getColor("primary", 9)};
`;
Nice and short! But unfortunately this doesn't really work :(. getColor
expects a third argument (props
). But we could make use of 'currying' here! Something like:
function color(type: ColorType, index: ColorIndex) {
return function(props: ThemeProps) {
return getColor(type, index, props);
};
}
But we don't want the 'curried' function the whole time:
const Div = styled.div<{ disabled?: boolean }>`
color: ${props => getColor("primary", props.disabled ? 5 : 9, props)};
`;
We could create a utility function that detects whether we want a curried version or not:
// selectors.ts
import { ThemeProps } from "./theme";
type Resolver<T> = (props: ThemeProps) => T;
type OmitThemeProp<Args extends any[]> = Args[2] extends ThemeProps
? [Args[0], Args[1]]
: [Args[0]];
function createSelector<T extends (...args: any) => any>(getter: T) {
function select(
...args: OmitThemeProp<Parameters<T>>
): Resolver<ReturnType<T>>;
function select(...args: Parameters<T>): ReturnType<T>;
function select(...args: any): any {
if (args.length === getter.length) {
return getter(...args);
}
return (props: ThemeProps) => getter(...args, props);
}
return select;
}
Wow! What the &^%^ is that!? Don't worry if you don't see it right away, I'll do my best to clarify:
// this expresses the return-type when we want a curried version
type Resolver<T> = (props: ThemeProps) => T;
// An utility-type that removes the `props` from the `getter` parameters
type OmitThemeProp<Args extends any[]> = Args[2] extends ThemeProps
? [Args[0], Args[1]]
: [Args[0]];
// utility function that accepts a `getter` function we've defined earlier...
function createSelector<T extends (...args: any) => any>(getter: T) {
// 2 overloads:
// A. WITHOUT props provided...
function select(
...args: OmitThemeProp<Parameters<T>>
): Resolver<ReturnType<T>>;
// B. WITH props provided
function select(...args: Parameters<T>): ReturnType<T>;
// the last declaration is needed for the overloads the work
function select(...args: any): any {
// if the user passed in the same number of arguments
// the 'getter' function is asking for, apply the
// function right away
if (args.length === getter.length) {
return getter(...args);
}
// ...otherwise, return a 'curried' version
return (props: ThemeProps) => getter(...args, props);
}
return select;
}
The good news is, we can now create those curried getters super easy! This might not seem a big deal right now, but when you eventually have 12+ token types, it becomes really convenient.
// selectors.ts
import { getColor, getSpace } from "./getters";
function createSelector() {
/* skipped for brevity */
}
export const color = createSelector(getColor);
export const space = createSelector(getSpace);
Step 4: useTheme()
Let's create the body of our hook first:
// useTheme.tsx
import * as React from "react";
function useTheme() {
// we will replace these in a moment
return {
color: () => null,
space: () => null,
};
}
// later on in your app:
const theme = useTheme();
theme.color("primary", 9); // hsl(205, 100%, 21%)
Ok, first we need access to styled-components ThemeContext
in order to retrieve the theme that was provided by <ThemeProvider />
.
// useTheme.tsx
import { ThemeContext } from "styled-components";
// useTheme.ts
function useTheme() {
const theme = useContext(ThemeContext);
// we will replace these in a moment
return {
color: () => null,
space: () => null,
};
}
Next, we need to import our 'getter' functions, and use them:
// useTheme.tsx
import * as React from "react";
import { ThemeContext } from "styled-components";
import { getColor, getSpace } from "./getters";import { ColorType, ColorIndex, SpaceIndex } from "./theme";
function useTheme() {
const theme = useContext(ThemeContext);
const props = { theme }; return { color: (type: ColorType, index: ColorIndex) => getColor(type, index, props), space: (index: SpaceIndex) => getSpace(index, props), };}
This will work, bit there's some duplication going on that I don't like. There's a pattern here: The hook returns a couple of functions that matches the getter functions, except for the props
({ theme }
) from the ThemeContext
. We could write a similar utility function we wrote earlier:
// useTheme.tsx
function createPartialGetter<T extends (...args: any) => any>(
getter: T,
props: ThemeProps
) {
return function x(...args: OmitThemeProp<Parameters<T>>): ReturnType<T> {
return getter(...args, props);
};
}
Basically this is a utility function that very similarly creates a curried version, only this time we already have the props
argument, and are waiting for the rest of the getter's arguments.
If we apply this utility function we get:
// useTheme.tsx
import { useContext, useMemo } from "react";
import { ThemeContext } from "styled-components";
import { ThemeProps } from "./theme";
import { getColor, getSpace } from "./getters";
import { OmitThemeProp } from "./types";
function createPartialGetter<T extends (...args: any) => any>(
getter: T,
props: ThemeProps
) {
return function x(...args: OmitThemeProp<Parameters<T>>): ReturnType<T> {
return getter(...args, props);
};
}
function useTheme() {
const theme = useContext(ThemeContext);
const themeProps = { theme };
return {
color: createPartialGetter(getColor, themeProps),
space: createPartialGetter(getSpace, themeProps),
};
}
Step 5: Export
What's left is exposing our functions to the outside world!
// index.ts
import * as theme from "./selectors";
import useTheme from "./useTheme";
import defaultTheme, { Theme } from "./theme";
export { theme, useTheme, defaultTheme, Theme };
Step 6: Implement
import * as React from "react";
import * as ReactDOM from "react-dom";
import styled, { ThemeProvider } from "styled-components";
import { defaultTheme, theme, useTheme } from "@company/theme";
const StyledComponent = styled.div`
color: ${theme.color("primary", 9)};
margin-top: ${theme.space(2)};
`;
function RegularComponent() {
const theme = useTheme();
return (
<div
style={{
color: theme.color("primary", 5),
marginTop: theme.space(2),
}}
>
We're accessing tokens inside a component :)!
</div>
);
}
function App() {
return (
<ThemeProvider theme={defaultTheme}>
<div>
<StyledComponent>Theme with styled-components!!!</StyledComponent>
<RegularComponent />
</div>
</ThemeProvider>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
Conclusion
I hope I gave you some insight in how to manage tokens with Typescript and styled-components. I also created a repository on GitHub where you can find the entire source, so you could build your own little package for instance. Let me know if you got something!
Thanks for reading!
Please, follow me on Twitter to receive the latest updates!