Looking for a Front-End Developer and Design Systems Practitioner?

I currently have some availabilty. Let's talk!.

Always Twisted

A Design Tokens Workflow (part 13)

Generating Utility Classes from Design Tokens using Style Dictionary

  1. Getting Started With Style Dictionary
  2. Outputting to Different Formats with Style Dictionary
  3. Beyond JSON: Exploring File Formats for Design Tokens
  4. Converting Tokens with Style Dictionary
  5. Organising Outputs with Style Dictionary
  6. Layers, referencing tokens in Style Dictionary
  7. Implementing Light and Dark Mode with Style Dictionary
  8. Implementing Light and Dark Mode with Style Dictionary (part 2)
  9. Implementing Multi-Brand Theming with Style Dictionary
  10. Creating Multiple Themes with Style Dictionary
  11. Creating Sass-backed CSS Custom Properties With Style Dictionary
  12. Creating a Penpot Design Tokens Format with Style Dictionary
  13. Generating Utility Classes from Design Tokens using Style Dictionary – You are here
On This Page:

This article shows a practical pattern for generating utility classes in CSS I’ve used in one form or another since 2016. Utility classes can give us a fast, consistent way to apply design system values in markup, without sprinkling hard‑coded styles across your project. As I discussed in my article on Creating Design System Friendly Snowflakes with Utility Classes, utility classes can really help offer a middle ground between rigid components and completely custom CSS.

By generating utility classes directly from design tokens, we ensure that even any “one-off designs” stay consistent with our design system's values. If a design needs a specific spacing or colour that exists in our tokens, we can use a utility class rather than writing custom CSS with hard-coded values.

Defining Our Design Tokens

To get things going, let’s define some design tokens for colour, spacing, and typography.

Colours

Code languagejson
{
"color": {
"red": {
"500": {
"$value": "#f00",
"$type": "color"
},
"400": {
"$value": "#ff4d4d",
"$type": "color"
},
"300": {
"$value": "#ff9999",
"$type": "color"
}
},
"blue": {
"500": {
"$value": "#007bff",
"$type": "color"
},
"400": {
"$value": "#66b3ff",
"$type": "color"
},
"300": {
"$value": "#99ccff",
"$type": "color"
}
},
"green": {
"500": {
"$value": "#28a745",
"$type": "color"
},
"400": {
"$value": "#66d69f",
"$type": "color"
},
"300": {
"$value": "#99e6b3",
"$type": "color"
}
}
}
}

Spacing

Code languagejson
{
"spacing": {
"100": {
"$value": "0.25rem",
"$type": "dimension"
},
"200": {
"$value": "0.5rem",
"$type": "dimension"
},
"300": {
"$value": "0.75rem",
"$type": "dimension"
},
"400": {
"$value": "1rem",
"$type": "dimension"
},
"500": {
"$value": "1.5rem",
"$type": "dimension"
},
"600": {
"$value": "2rem",
"$type": "dimension"
},
"700": {
"$value": "3rem",
"$type": "dimension"
}
}
}

Typography

I’m going to split the examples for typography tokens into three separate files: font-size.tokens, font-weight.tokens, and font-family.tokens. This separation makes sense because font sizes, weights, and families often change at different rates (sizes might need frequent adjustments, while families rarely change), smaller, more focused files are easier to navigate and update rather than one large typography file, and we might decide to only generate utilities for sizes and weights but not families, for example.

Font Size

Code languagejson
{
"font": {
"size": {
"100": {
"$value": "0.75rem",
"$type": "dimension"
},
"200": {
"$value": "0.875rem",
"$type": "dimension"
},
"300": {
"$value": "1rem",
"$type": "dimension"
},
"400": {
"$value": "1.125rem",
"$type": "dimension"
},
"500": {
"$value": "1.25rem",
"$type": "dimension"
},
"600": {
"$value": "1.5rem",
"$type": "dimension"
},
"700": {
"$value": "1.75rem",
"$type": "dimension"
},
"800": {
"$value": "2rem",
"$type": "dimension"
}
}
}
}

Font Weight

Code languagejson
{
"font": {
"weight": {
"200": {
"$value": "200",
"$type": "fontWeight"
},
"500": {
"$value": "500",
"$type": "fontWeight"
},
"700": {
"$value": "700",
"$type": "fontWeight"
}
}
}
}

Font Family

Code languagejson
{
"font": {
"family": {
"base": {
"$value": "Arial, sans-serif",
"$type": "fontFamily"
},
"code": {
"$value": "Consolas, monospace",
"$type": "fontFamily"
}
}
}
}

Utility Class Generation Script

When generating utility classes with Style Dictionary, how we structure our config or build script can make a real difference to how maintainable and scalable our setup becomes. I'm going to show two approaches. First, keeping everything in our ⁠build.js file, and then secondly, extracting the configuration into a separate ⁠utilities.js file.

Code languagejavascript
import StyleDictionary from 'style-dictionary';
// Register custom format for spacing utilities
StyleDictionary.registerFormat({
name: 'css/utility-spacing',
format: function({ dictionary }) {
const utilities = [
{ prefix: 'padding', property: 'padding' },
{ prefix: 'padding-top', property: 'padding-top' },
{ prefix: 'padding-right', property: 'padding-right' },
{ prefix: 'padding-bottom', property: 'padding-bottom' },
{ prefix: 'padding-left', property: 'padding-left' },
{ prefix: 'margin', property: 'margin' },
{ prefix: 'margin-top', property: 'margin-top' },
{ prefix: 'margin-right', property: 'margin-right' },
{ prefix: 'margin-bottom', property: 'margin-bottom' },
{ prefix: 'margin-left', property: 'margin-left' }
];
let output = '/* Spacing Utilities */\n\n';
utilities.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'spacing')
.forEach(token => {
const className = `${util.prefix}-${token.path[token.path.length - 1]}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
// Register custom format for colour utilities
StyleDictionary.registerFormat({
name: 'css/utility-color',
format: function({ dictionary }) {
const utilities = [
{ prefix: 'text', property: 'color' },
{ prefix: 'background', property: 'background-color' },
{ prefix: 'border', property: 'border-color' }
];
let output = '/* Colour Utilities */\n\n';
utilities.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'color')
.forEach(token => {
const colorName = token.path.slice(1).join('-');
const className = `${util.prefix}-${colorName}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
// Register custom format for font-size utilities
StyleDictionary.registerFormat({
name: 'css/utility-font-size',
format: function({ dictionary }) {
let output = '/* Font Size Utilities */\n\n';
dictionary.allTokens
.filter(token => token.path[0] === 'font' && token.path[1] === 'size')
.forEach(token => {
const size = token.path[token.path.length - 1];
const className = `font-size--${size}`;
output += `.${className} {\n font-size: ${token.$value};\n}\n\n`;
});
return output;
}
});
// Register custom format for font-weight utilities
StyleDictionary.registerFormat({
name: 'css/utility-font-weight',
format: function({ dictionary }) {
let output = '/* Font Weight Utilities */\n\n';
dictionary.allTokens
.filter(token => token.path[0] === 'font' && token.path[1] === 'weight')
.forEach(token => {
const weight = token.path[token.path.length - 1];
const className = `font-weight--${weight}`;
output += `.${className} {\n font-weight: ${token.$value};\n}\n\n`;
});
return output;
}
});
// Register custom format for font-family utilities
StyleDictionary.registerFormat({
name: 'css/utility-font-family',
format: function({ dictionary }) {
let output = '/* Font Family Utilities */\n\n';
dictionary.allTokens
.filter(token => token.path[0] === 'font' && token.path[1] === 'family')
.forEach(token => {
const family = token.path[token.path.length - 1];
const className = `font-family--${family}`;
output += `.${className} {\n font-family: ${token.$value};\n}\n\n`;
});
return output;
}
});
// Build configuration
const myStyleDictionary = new StyleDictionary({
source: ['src/tokens/base/**/*.tokens'],
preprocessors: ['tokens-studio'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'build/css/',
files: [
{
destination: 'utilities-spacing.css',
format: 'css/utility-spacing'
},
{
destination: 'utilities-color.css',
format: 'css/utility-color'
},
{
destination: 'utilities-font-size.css',
format: 'css/utility-font-size'
},
{
destination: 'utilities-font-weight.css',
format: 'css/utility-font-weight'
},
{
destination: 'utilities-font-family.css',
format: 'css/utility-font-family'
}
]
}
}
});
await myStyleDictionary.buildAllPlatforms();

Understanding the Custom Formats

Let's break down how StyleDictionary.registerFormat() works using the colour utilities as an example:

Code languagejavascript
StyleDictionary.registerFormat({
name: 'css/utility-color',
format: function({ dictionary }) {
// ... format logic
}
});

StyleDictionary.registerFormat() tells Style Dictionary "I want to create a new output format that we can use when generating files." The name property (’css/utility-color' in this case) is the unique identifier for our format. Later, when we configure the build, we reference this name to tell Style Dictionary which format to use for a specific file—in our case, we use format: 'css/utility-color' in the files array to connect this custom format to the utilities-color.css output file.

Code languagejavascript
files: [
{
destination: 'utilities-color.css',
format: 'css/utility-color' // References the name here
}
]

Here in the build configuration, we reference the format by its name (⁠’css/utility-color') to tell Style Dictionary which custom format to use when generating the ⁠utilities-color.css file.

Inside the format function:

Code languagejavascript
format: function({ dictionary }) {
// Define what we want to generate
const utilities = [
{ prefix: 'text', property: 'color' },
{ prefix: 'background', property: 'background-color' },
{ prefix: 'border', property: 'border-color' }
];
// Start building the CSS output as a string
let output = '/* Colour Utilities */\n\n';
// Loop through each utility type (text, background, border)
utilities.forEach(util => {
// Access all tokens and filter to only colour tokens
dictionary.allTokens
.filter(token => token.path[0] === 'color')
.forEach(token => {
// Build the class name from the token path, token.path might be ['color', 'red', '500']. slice(1) removes 'color', leaving ['red', '500']. join('-') creates 'red-500'
const colorName = token.path.slice(1).join('-');
const className = `${util.prefix}-${colorName}`;
// Generate the CSS rule
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
// Return the complete CSS string
return output;
}

There are a few key things to understand here. ⁠dictionary.allTokens is an array containing every token from our files after they've been processed. Each token has a ⁠path property representing the structure of our token. For example, a token at ⁠color.red.500 has the path ⁠[’color', 'red', '500']. The ⁠token.$value property contains the value (e.g., ⁠#f00). What’s returned becomes the content for the generated file(s).

When Style Dictionary runs, it loads all of the tokens found in ⁠src/tokens/base/**/*.tokens, it looks for the file configuration that uses ⁠format: 'css/utility-color', calls the custom format function, and takes the returned information and generates ⁠build/css/utilities-color.css.

This will create a CSS file like:

Code languagecss
/* Colour Utilities */
.text-red-500 {
color: #f00;
}
.text-red-400 {
color: #ff4d4d;
}
.bg-blue-500 {
background-color: #007bff;
}
/* ... and so on */

Improving the build script with a separate config file

Even with only creating utility classes the build.js is starting to feeling crowded, so we’re going to ‘extract’ the configuration data into a separate file. This separates what utilities we want from how they're generated.

Create a config/utilities.js file

Code languagejavascript
export const utilityConfig = {
spacing: [
{ prefix: 'padding', property: 'padding' },
{ prefix: 'padding-top', property: 'padding-top' },
{ prefix: 'padding-right', property: 'padding-right' },
{ prefix: 'padding-bottom', property: 'padding-bottom' },
{ prefix: 'padding-left', property: 'padding-left' },
{ prefix: 'margin', property: 'margin' },
{ prefix: 'margin-top', property: 'margin-top' },
{ prefix: 'margin-right', property: 'margin-right' },
{ prefix: 'margin-bottom', property: 'margin-bottom' },
{ prefix: 'margin-left', property: 'margin-left' }
],
color: [
{ prefix: 'text', property: 'color' },
{ prefix: 'background', property: 'background-color' },
{ prefix: 'border', property: 'border-color' }
],
fontSize: [
{ prefix: 'font-size', property: 'font-size' }
],
fontWeight: [
{ prefix: 'font-weight', property: 'font-weight' }
],
fontFamily: [
{ prefix: 'font-family', property: 'font-family' }
]
};

With this file now available it’s time to update the build.js to import it and use it:

Code languagejavascript
import StyleDictionary from 'style-dictionary';
import { utilityConfig } from './config/utilities.js';
// Register custom format for spacing utilities
StyleDictionary.registerFormat({
name: 'css/utility-spacing',
format: function ({ dictionary }) {
let output = '/* Spacing Utilities */\n\n';
utilityConfig.spacing.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'spacing')
.forEach(token => {
const className = `${util.prefix}--${token.path[token.path.length - 1]}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
// Register custom format for colour utilities
StyleDictionary.registerFormat({
name: 'css/utility-color',
format: function ({ dictionary }) {
let output = '/* Colour Utilities */\n\n';
utilityConfig.color.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'color')
.forEach(token => {
const colorName = token.path.slice(1).join('--');
const className = `${util.prefix}-${colorName}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
StyleDictionary.registerFormat({
name: 'css/utility-font-size',
format: function ({ dictionary }) {
let output = '/* Font Size Utilities */\n\n';
utilityConfig.fontSize.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'font' && token.path[1] === 'size')
.forEach(token => {
const size = token.path[token.path.length - 1];
const className = `${util.prefix}--${size}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
StyleDictionary.registerFormat({
name: 'css/utility-font-weight',
format: function ({ dictionary }) {
let output = '/* Font Weight Utilities */\n\n';
utilityConfig.fontWeight.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'font' && token.path[1] === 'weight')
.forEach(token => {
const weight = token.path[token.path.length - 1];
const className = `${util.prefix}--${weight}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
StyleDictionary.registerFormat({
name: 'css/utility-font-family',
format: function ({ dictionary }) {
let output = '/* Font Family Utilities */\n\n';
utilityConfig.fontFamily.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'font' && token.path[1] === 'family')
.forEach(token => {
const family = token.path[token.path.length - 1];
const className = `${util.prefix}--${family}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});
// Build configuration
const myStyleDictionary = new StyleDictionary({
source: ['src/tokens/base/**/*.tokens'],
preprocessors: ['tokens-studio'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'build/css/',
files: [
{
destination: 'utilities-spacing.css',
format: 'css/utility-spacing'
},
{
destination: 'utilities-color.css',
format: 'css/utility-color'
},
{
destination: 'utilities-font-size.css',
format: 'css/utility-font-size'
},
{
destination: 'utilities-font-weight.css',
format: 'css/utility-font-weight'
},
{
destination: 'utilities-font-family.css',
format: 'css/utility-font-family'
}
]
}
}
});
await myStyleDictionary.buildAllPlatforms();

What’s changed here?

As mentioned above, the difference between these two approaches is where the utility definitions are defined. In the first approach, they are defined directly inside each format function.

Code languagejavascript
StyleDictionary.registerFormat({
name: 'css/utility-color',
format: function({ dictionary }) {
const utilities = [ // Defined here, inside the function
{ prefix: 'text', property: 'color' },
{ prefix: 'background', property: 'background-color' },
{ prefix: 'border', property: 'border-color' }
];
utilities.forEach(util => {
// ... generate CSS
});
}
});

By separating the configuration for the utilities we are importing them from the external file into the build.js script

Code languagejavascript
import { utilityConfig } from './config/utilities.js';
StyleDictionary.registerFormat({
name: 'css/utility-color',
format: function({ dictionary }) {
// No utilities array defined here
utilityConfig.color.forEach(util => { // Using imported config instead
// ... generate CSS (same logic as before)
});
}
});

The format function itself does exactly the same work, looping through utilities and generating CSS. The only difference is the source of the utility definitions. Instead of hardcoding them in the build script, we're pulling them from ⁠utilityConfig.color, which comes from our separate config/utilities.js configuration file.

This way when we want to add a new utility (say, ⁠.outline-red-500 for ⁠outline-color), we edit ⁠config/utilities.js and add ⁠{ prefix: 'outline', property: 'outline-color' } to the ⁠colour array. The build script doesn't need to change at all and should automatically pick up the new definitions and generate the relevant utility classes.

Comparing the Approaches

Starting with everything in ⁠build.js can work well because everything is visible in one file and we can see exactly what's happening. There are no additional files to manage or import, making it perfect for getting started or for smaller projects. It's also easy to debug since all the logic is in one place.

However, this approach could fall short as the project grows. As we add more utility types, other formats (.scss, .js that are needed for the work the file can quickly grow, and the utility definitions could get mixed with the generation logic. If we wanted to reuse this setup in another project, we might need to copy the entire file, and making changes to utility definitions requires editing the build script itself.

Moving to a ‘separated configuration’ can improve things considerably. The configuration for the utility classes is separated from logic, making it easier to see what we’re going to be generating. Our ⁠build.js should become cleaner and more focused on only the generation process. The configuration file could be shared across projects and non-developers can more easily make changes to utility definitions without touching the build logic.

However, there are some things to consider with this approach. We will now have (at least) two files to maintain instead of one, the build script still needs to register each format individually, and we are still repeating similar logic for each utility type. But for most projects, these trade-offs are worth the improved organisation and maintainability.

Generated Output

Regardless of which configuration approach we choose, the output remains the same. Here's what gets generated:

utilities-spacing.css

Code languagecss
/* Spacing Utilities */
.padding--100 {
padding: 0.25rem;
}
.padding--200 {
padding: 0.5rem;
}
.padding-top--100 {
padding-top: 0.25rem;
}
.margin-top--400 {
margin-top: 1rem;
}
/* ... more utilities */

utilities-color.css

Code languagecss
/* Colour Utilities */
.text-red--500 {
color: #f00;
}
.bg-blue--400 {
background-color: #66b3ff;
}
.border-green--300 {
border-color: #99e6b3;
}
/* ... more utilities */

utilities-font-size.css

Code languagecss
/* Font Size Utilities */
.font-size--100 {
font-size: 0.75rem;
}
.font-size--300 {
font-size: 1rem;
}
.font-size--600 {
font-size: 1.5rem;
}
/* ... more utilities */

Generating Semantic Utility Classes

While the utilities we've generated so far use the raw token names (like .text-500 or .background-blue-400), it could be better if we generated more semantic utility classes that better communicate intent of our design decisions.

For example, if we have semantic colour tokens file like:

Code languagejson
{
"color": {
"semantic": {
"primary": {
"$value": "{color.blue.500}",
"$type": "color"
},
"danger": {
"$value": "{color.red.500}",
"$type": "color"
},
"success": {
"$value": "{color.green.500}",
"$type": "color"
}
}
}
}

We could create a separate format that generates semantic utilities:

Code languagejavascript
StyleDictionary.registerFormat({
name: 'css/utility-semantic-color',
format: function({ dictionary }) {
const utilities = [
{ prefix: 'text', property: 'color' },
{ prefix: 'background', property: 'background-color' },
{ prefix: 'border', property: 'border-color' }
];
let output = '/* Semantic Colour Utilities */\n\n';
utilities.forEach(util => {
dictionary.allTokens
.filter(token => token.path[0] === 'color' && token.path[1] === 'semantic')
.forEach(token => {
const semanticName = token.path[token.path.length - 1];
const className = `${util.prefix}--${semanticName}`;
output += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;
});
});
return output;
}
});

This would generate utilities like:

Code languagecss
.text--primary { color: #007bff; }
.background--danger { background-color: #f00; }
.border--success { border-color: #28a745; }

This approach can give us both the flexibility of low-level utilities (.text-500) and the clarity of semantic utilities (.text-primary).

Generating utility classes from our design tokens can help ‘bridge the gap’ between our design system and the potential fast-paced, real-world needs of building interfaces.

By automating this process with Style Dictionary, we can ensure that utility classes should stay in sync with our tokens. When we update a token value, the utility classes can be automatically updated. Teams are now constrained to use system values even for one-off designs, keeping everything aligned and being able to create things that are ‘of the Design System’ but not necessarily something that will ever become something that is ‘from the Design System’.

You can find the code for this article in this branch of the github repo for this series.

Struggling to decide how to name your design tokens consistently across your system?

I can guide you in creating a naming convention that’s intuitive and consistent.

get in touch!