Using Style Dictionary to transform Tailwind config into SCSS variables, CSS custom properties, and JavaScript via design tokens

Design tokens are machine-readable representations of a brand's design language, typically things like font families, font scales, colours, a sizing scale and so on. The key thing is that they are stored in a technology-and-platform-agnostic format like JSON or YAML, and then transformed into more specific formats (like hex colours for web in a JavaScript object, dp dimensions for Android development, or a Sketch colour palette file) by automated tooling. The idea is that whether you're making a website, Android app, slide deck, or print brochure you can always consume this JSON or YAML file, transform the design tokens to the format that suits your project, and avoid any hard-coded values or manual transformations.

'Design tokens' are a term coined by Jina Anne who explains them in her own words:

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.

I've been looking into design token tools like Theo and Style Dictionary recently. These are the tools that take tokens from a JSON or YAML file and transform the format and/or alter the values.

For example, you could use these tools to import tokens from JSON or YAML then export them in web-friendly formats to a JavaScript object or module. Then, you could import that file in your Tailwind config file and reference the design tokens by name, rather than having explicit colours and font-size values defined in your Tailwind config. We won't be talking about that approach today, although if you're interested in that I can recommend this blog post by Michael Mangialardi: 'Integrating Design Tokens With Tailwind'
.

The transformation ability of Style Dictionary gave me an idea. At work, I currently use a collection of hand-written Node scripts to transform our Tailwind config (colours, spacing values, font sizes etc) into SCSS variables, and I've also been looking at how to convert colours to CSS custom properties for when we eventually drop Sass and rely on modern CSS features and Postcss. We use these exported SCSS variables in non-Tailwind SCSS code, like when it makes more sense to write traditional CSS than to rely on utilities, or for customising the theme of third-party components that provide SCSS files to work with.

My scripts are okay, but they feel a bit bodged-together. It'd be nice to use a purpose-made tool. There is a project called dobromir-hristov/tailwindcss-export-config that does this sort of thing, but it doesn't support exporting to CSS custom properties or any other non-CSS-preprocessor-related formats. Style Dictionary, however, supports a huge range of formats and it is specialised at transforming values – unlike my homemade Node.js scripts!

Let's use Style Dictionary to transform our Tailwind config into whatever formats we want!

Installation

From a blank repo, let's install the tools we need:

# Make sure you're using a recent and supported version 
# of Node. I like to use nvm to manage my Node versions.
nvm use --lts
# We can track our changes with Git.
git init
# Create a package.json file by answering some prompts: 
npm init
# Install Tailwind, its dependencies, and create 
# a basic Tailwind config file.
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init
# Install Style Dictionary.
npm install -D style-dictionary

Feeding design tokens into Style Dictionary

First of all, let's create a style-dictionary.config.js file with a single design token, just so we can experiment with it:

module.exports = {
  tokens: {
    color: {
      primary: { value: '#fbbf24' },
    },
  },
  platforms: {
    scss: {
      transformGroup: 'scss',
      buildPath: 'src/scss/',
      files: [
        {
          destination: '_variables.scss',
          format: 'scss/variables',
        },
      ],
    },
  },
};

Let's also add an entry to our package.json's scripts object, to allow us to tell Style Dictionary to do its thing:

"scripts": {
    "style-dictionary:build": "style-dictionary build --config ./style-dictionary.config.js"
  },

Now, if we run npm run style-dictionary:build we should end up with a new file created at src/scss/_variables.scss with this inside it:

// Do not edit directly
// Generated on Tue, 06 Jul 2021 20:21:18 GMT

$color-primary: #fbbf24;

Not bad! Now let's do CSS custom properties too. We'll add a new platform entry to our style-dictionary.config.js file. We can write the CSS variables straight to our dist directory, ready to link to in our application:

module.exports = {
  // …
  platforms: {
    // …
    css: {
      transformGroup: 'css',
      buildPath: 'dist/css/',
      files: [
        {
          format: 'css/variables',
          destination: 'variables.css',
        },
      ],
    },
  },
};

If we run our build command again, then we should get a new dist/css/variables.css file that contains this content:

/**
 * Do not edit directly
 * Generated on Tue, 06 Jul 2021 20:25:36 GMT
 */

:root {
  --color-primary: #fbbf24;
}

Nice! This is so much easier than my homemade approach. Tooling FTW!

Now, let's hook it up to Tailwind!

So far we've been testing with a single primary colour design token, but what we really want to do is pull in all the colours that we already have in our Tailwind configuration.

Luckily for us, the Tailwind maintainers have already thought of this and we can load our Tailwind config entries in non-Tailwind JavaScript by doing something like this example from the docs:

import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from './tailwind.config.js'

const fullConfig = resolveConfig(tailwindConfig)

fullConfig.theme.width[4]
// => '1rem'

fullConfig.theme.screens.md
// => '768px'

fullConfig.theme.boxShadow['2xl']
// => '0 25px 50px -12px rgba(0, 0, 0, 0.25)'

Let's try hooking that example up to our existing Style Dictionary config file. We'll focus on colours, for now:

const resolveConfig = require('tailwindcss/resolveConfig');
const tailwindConfig = require('./tailwind.config.js');

const { theme } = resolveConfig(tailwindConfig);

const tokens = { color: theme.colors };
console.log(tokens);

module.exports = {
  tokens,
  platforms: {
    scss: {
      transformGroup: 'scss',
      buildPath: 'src/scss/',
      files: [
        {
          destination: '_variables.scss',
          format: 'scss/variables',
        },
      ],
    },
    css: {
      transformGroup: 'css',
      buildPath: 'dist/css/',
      files: [
        {
          format: 'css/variables',
          destination: 'variables.css',
        },
      ],
    },
  },
};

If we run the build command now we will see all the colours logged to the terminal, but there won't be any content in our CSS or SCSS files. This is because Style Dictionary doesn't accept tokens as key/value pairs (or nested objects in the case of colour shades), instead the colour value needs to be within a value property, like this:

color: {
  background: {
    transparent: { value: "transparent" }
  }
}

That's no problem though, because we can manipulate our tokens object to match that format before we pass it to Style Dictionary.

Getting Tailwind config data into the right format for Style Dictionary

Up to now, I'd been writing this blog post as I worked, updating it with each change I made. However, at this point, I got stuck in a bit of a rabbit hole of trying various approaches to get the data into the right format, and also trying to handle the different formats that Tailwind config is stored in.

Some Tailwind config is a simple key/value pair like sm: '640px' for a screens value. Colours can be a key/pair like transparent: 'transparent' but also a key and object containing different shades, like this:

"indigo": {
  "50": "#eef2ff",
  "100": "#e0e7ff",
  "200": "#c7d2fe",
  "300": "#a5b4fc",
  "400": "#818cf8",
  "500": "#6366f1",
  "600": "#4f46e5",
  "700": "#4338ca",
  "800": "#3730a3",
  "900": "#312e81"
}
sans: [
  'ui-sans-serif',
  'system-ui',
  '-apple-system',
  'BlinkMacSystemFont',
  '"Segoe UI"',
  'Roboto',
  '"Helvetica Neue"',
  'Arial',
  '"Noto Sans"',
  'sans-serif',
  '"Apple Color Emoji"',
  '"Segoe UI Emoji"',
  '"Segoe UI Symbol"',
  '"Noto Color Emoji"',
],

I tried a variety of approaches of looping over the object and detecting the category or format of the data (e.g string, object, or array) and manipulating it. One approach made heavy use of map and looked smart but was utterly unreadable. Another made a lot of use of forEach but I needed to mess around with Object.fromEntries and Object.entries to convert my object into an array to loop over. Hooray for no dependencies, but it was a bit of a song and a dance! Eventually, I fell back to my old friend, Lodash. Not the trendiest of options, but it works, and it'll be understandable to a wider audience.

I ended up with something like this:

const resolveConfig = require('tailwindcss/resolveConfig');
const tailwindConfig = require('./tailwind.config.js');
const _ = require('lodash');

// Grab just the theme data from the Tailwind config.
const { theme } = resolveConfig(tailwindConfig);

// Create an empty object to hold our transformed tokens data.
const tokens = {};

// A helper function that uses Lodash's setWidth method to
// insert things into an object at the right point in the
// structure, and to create the right structure for us
// if it doesn't already exist.
const addToTokensObject = function (position, value) {
  _.setWith(tokens, position, { value: value }, Object);
};

// Loop over the theme data…
_.forEach(theme, function (value, key) {
  switch (key) {
    case 'fontFamily':
      // Font family data is in an array, so we use join to
      // turn the font families into a single string.
      _.forEach(theme['fontFamily'], function (value, key) {
        addToTokensObject(
          ['fontFamily', key],
          theme['fontFamily'][key].join(',')
        );
      });
      break;

    case 'fontSize':
      // Font size data contains both the font size (makes
      // sense!) but also a recommended line-length, so we
      // create two tokens for every font size, one for the
      // font-size value and one for the line-height.
      _.forEach(theme['fontSize'], function (value, key) {
        addToTokensObject(['fontSize', key], value[0]);
        addToTokensObject(
          ['fontSize', `${key}--lineHeight`],
          value[1]['lineHeight']
        );
      });
      break;

    default:
      _.forEach(value, function (value, secondLevelKey) {
        if (!_.isObject(value)) {
          // For non-objects (simple key/value pairs) we can
          // add them straight into our tokens object.
          addToTokensObject([key, secondLevelKey], value);
        } else {
          // Skip 'raw' CSS media queries.
          if (!_.isUndefined(value['raw'])) {
            return;
          }

          // For objects (like color shades) we need to do a
          // final forOwn loop to make sure we add everything
          // in the right format.
          _.forEach(value, function (value, thirdLevelKey) {
            addToTokensObject([key, secondLevelKey, thirdLevelKey], value);
          });
        }
      });
      break;
  }
});

It worked! But I was getting a LOT of tokens exported. This was okay for the SCSS option as SCSS variables would not be included in the compiled CSS, but for my CSS custom properties the size of the tokens file would add considerable bloat to any project.

Luckily, Style Dictionary supports filtering the exported tokens, so I made a long list of config categories that I was interested in exporting to SCSS, and a shorter list that I was interested in exporting to CSS custom properties. I also took this chance to add a t- prefix to all my exported tokens, to help prevent collisions with any existing code or third-party code.

const tokenPrefix = 't-';

const limitedFilter = (token) =>
  ['colors', 'spacing', 'fontFamily'].includes(token.attributes.category);

const fullFilter = (token) =>
  [
    'screens',
    'colors',
    'spacing',
    'opacity',
    'borderRadius',
    'borderWidth',
    'boxShadow',
    'fontFamily',
    'fontSize',
    'fontWeight',
    'letterSpacing',
    'lineHeight',
    'maxWidth',
    'zIndex',
    'scale',
    'transitionProperty',
    'transitionTimingFunction',
    'transitionDuration',
    'transitionDelay',
    'animation',
  ].includes(token.attributes.category);

module.exports = {
  tokens,
  platforms: {
    scss: {
      transformGroup: 'scss',
      prefix: tokenPrefix,
      buildPath: 'src/scss/',
      files: [
        {
          format: 'scss/variables',
          destination: '_variables.scss',
          filter: fullFilter,
        },
      ],
    },
    css: {
      transformGroup: 'css',
      prefix: tokenPrefix,
      buildPath: 'dist/css/',
      files: [
        {
          format: 'css/variables',
          destination: 'variables.css',
          filter: limitedFilter,
        },
      ],
    },
  },
};

Finally, drunk with power, I decided to also export my tokens as a JavaScript module so I could do things like import it into a Storybook instance to show the available tokens in a component library or style guide:

js: {
  transformGroup: 'js',
  prefix: tokenPrefix,
  buildPath: 'dist/js/',
  files: [
    {
      format: 'javascript/module',
      destination: 'tokens.js',
      filter: fullFilter,
      options: {
        outputReferences: true,
      },
    },
  ],
},

For the sake of making this blog post easier to read I have kept all this manipulating logic and the filtering functions inside the one file. But, these sort of things don't belong in the Style Dictionary configuration file. If you use this approach on a project then don't forget to break the code up into smaller modules and import them into the config file to keep things tidy.

27