Converting design tokens to CSS variables with Node.js
Converting design tokens is an error-prone process - I found about it the hard way. So, I made a simple Node.js script that will help me with that task.
Last Updated September 14, 2022![List of CSS variables in Visual Studio Code.](/static/148f502ba7e4329daa20da11a1d881cc/51e3c/pankaj-patel-4oAFasAPftg-unsplash.jpg)
Recently, I was converting JavaScript design tokens into CSS variables. I had the JS file with different website aspects stored in object properties: font sizes, spaces, colors, etc. It looked similar to this snippet.
JS
1//tokens.js23module.exports = {4 font: {5 family: {6 heading: "'Source Sans Pro', sans-serif",7 body: "'Roboto', sans-serif",8 },9 weight: {10 normal: "400",11 semibold: "500",12 },13 },14 color: {15 background: "#ffffff",16 primary: {17 light: "#4266b3",18 default: "#16233f",19 dark: "#06080f",20 },21 },22 //more tokens23}
I wanted to use CSS variables, so I copy-pasted properties from the JS file into a CSS file. Here’s the effect.
CSS
1/* tokens.css */23:root {4 --font-family-heading: "'Source Sans Pro', sans-serif";5 --font-family-body: "'Roboto', sans-serif";6 --font-weight-normal: "400";7 --font-weight-semibold: "500";8 --color-background: "#ffffff";9 --color-primary-light: "#4266b3";10 --color-primary-default: "#16233f";11 --color-primary-dark: "#06080f";12 /* more variables */13}
Suddenly, I time-traveled to the late 90s. My website looked like Tim Berners-Lee himself made it. There was almost no styling whatsoever. The layout was off. I also made other changes, and I thought there was a problem with the build phase or styled-components. I was debugging the problem for about an hour. . .just to discover that I forgot to delete unnecessary double quotes in the CSS file. Yeah, styled-components won’t work with invalid variables. I will stop to embarrass myself publicly and use this mistake to learn something publicly.
Goal
I want to automate this process of converting JS tokens into CSS tokens. The node script would take a JS file with nested theme properties and return a CSS file with correctly named CSS variables. I want something like the snippets above. But with no extra double quotes. I know there is probably a parser like this somewhere, but I want to write it from scratch and learn some node.js. And if there isn’t - I will feel better, not reinventing the wheel. You can learn with me. A little knowledge of JavaScript and CSS will be helpful to continue.
Node environment
I have Node.js already installed on my machine. If you don’t have this runtime, here’s the download link. You can also use nvm to manage multiple versions of the node. For this project, I will use the latest version - 18.3.0. You can check your node version with node -v
or nvm ls
.
Having node installed, let’s init new project with npm init
. We probably won’t use any third-party packages, but initiating a new one won’t hurt. After getting through the npm setup, we should get a package.json file.
Script for converting design tokens
With the environment set up, let’s create our first file - index.js. If everything works, running the script should print “hello” in our terminal.
JS
1console.log("Hello")
First, let’s import the node modules we will use. I’ll use the require()
syntax for dynamic imports. But you can also use standard ES modules - add the "type": "module"
field to package.json for that purpose.
JS
1//index.js23const { argv } = require("node:process")4const { parse, format, normalize } = require("node:path")5const { writeFile } = require("node:fs/promises")
Then we need to import style tokens from the JS file. We can use require again, but this time with a local path to styles as an argument.
JS
1const tokens = require("./tokens.js")
Having object with styles imported, it’s time for a more complex part - converting them into CSS styles. All tokens are in (nested) objects. For example, the body font size is sequentially nested in font, size, and body. We want to transform it into a CSS variable that looks like this: --font-size-body: 1.5rem
. So, let’s think about what we need to do. We need to concatenate keys from objects with hyphens, and when there are no more nested objects, we need to add a string value to our freshly constructed CSS variable.
JS
1const tokensToCss = (object, base = `-`) =>2 Object.entries(object).reduce((css, [key, value]) => {3 let newBase = base + `-${key}`4 if (typeof value !== "object") {5 return css + newBase + `: ${value};\n`6 }7 return css + tokensToCss(value, newBase)8 }, ``)
This short snippet can be a little mind-bending, so bear with me. We created a parse function with two parameters: object to parse and current base. With Object.entries()
method, we return key-value pairs inside an array. On the returned array, we use the reduce()
method. It takes two parameters: the callback function to execute with each array element and the initial value - an empty string. The mentioned callback takes two parameters: the previous value, where we will store accumulated variables, and the current value - array (destructured to key and value). Inside the callback, we immediately create a new base. It is an old base concatenated with a hyphen and the current key. We define CSS variables with two hyphens, so the base is a hyphen by default. We always want to concatenate the object key. The thinking goes to the value. There are only two possibilities: the value can be another nested object or primitive. If the value is an object, we also want to parse it. So, in that case, we return the accumulator plus the result of the function invoking itself. But, this time, the parsing function takes the value as an object. The nested object can have multiple properties, so the parsing function needs to take a new base and apply it to them all. If there are no more nested objects, we want to wrap up our CSS variable with the value, semicolon, and a new line. The result is a list of CSS variables - one under the other - created from a passed object.
JS
1const { name } = parse("./tokens.js")2const cssVariables = tokensToCss(tokens)3const cssClass = `:root {\n${cssVariables.replaceAll("--", " --")}}\n`45writeFile(`${name}.css`, cssClass)
CSS variables need to be in some class, so I put them inside the :root
pseudo-class to be globally available. I also added new lines and spaces to format it. I wrote the class to a CSS file with the writeFile()
method. The first argument is the name of the original JS file, but with the .css
extension. The second is our prepared string. Here’s the output of the CSS file.
CSS
1:root {2 --font-family-heading: "Source Sans Pro", sans-serif;3 --font-family-body: "Roboto", sans-serif;4 --font-weight-normal: 400;5 --font-weight-semibold: 500;6 --color-background: #ffffff;7 --color-primary-light: #4266b3;8 --color-primary-default: #16233f;9 --color-primary-dark: #06080f;10}
Our script works, but the path is hard-coded. I changed it to a variable. Then I used the argv
property to get command-line arguments. I sliced them because the first argument is the path of the node command, and the second is the path of the executed file. We want custom arguments from a user. I normalized and formatted the passed argument because the require()
method needs the leading slash for a local file. This way, it doesn’t matter if a user passes a script name tokens.js
or a relative path ./tokens.js
as an argument. In the end, I destructured the name from the original path to use it in the new CSS file.
JS
1const args = argv.slice(2)2const tokensPath = format({ root: "./", base: normalize(args[0]) })3const tokens = require(tokensPath)4const { name } = parse(tokensPath)
Now the script can be executed like this: node index.js tokens.js
.
Final script
I refactored the final script a bit. I added some basic error handling with custom messages. I also extracted saving logic to an asynchronous function because the writeFile()
method in our example is promise based.
JS
1const { argv } = require("node:process")2const { parse, format, normalize } = require("node:path")3const { writeFile } = require("node:fs/promises")45const tokensToCss = (object = {}, base = `-`) =>6 Object.entries(object).reduce((css, [key, value]) => {7 let newBase = base + `-${key}`8 if (typeof value !== "object") {9 return css + newBase + `: ${value};\n`10 }11 return css + tokensToCss(value, newBase)12 }, ``)1314const saveTokens = async (name, tokens) => {15 try {16 await writeFile(`${name}.css`, tokens)17 } catch (e) {18 console.log("There was an error while saving a file.\n", e)19 }20}2122try {23 const args = argv.slice(2)24 const tokensPath = format({ root: "./", base: normalize(args[0]) })25 const tokens = require(tokensPath)26 const { name } = parse(tokensPath)2728 const cssVariables = tokensToCss(tokens)29 const cssClass = `:root {\n${cssVariables.replaceAll("--", " --")}}\n`30 saveTokens(name, cssClass)31} catch (e) {32 console.log(33 "Provide a correct argument - a relative path to design tokens.\n",34 e35 )36}