A Design Tokens Workflow (part 13)
Generating Utility Classes from Design Tokens using Style Dictionary
- Getting Started With Style Dictionary
- Outputting to Different Formats with Style Dictionary
- Beyond JSON: Exploring File Formats for Design Tokens
- Converting Tokens with Style Dictionary
- Organising Outputs with Style Dictionary
- Layers, referencing tokens in Style Dictionary
- Implementing Light and Dark Mode with Style Dictionary
- Implementing Light and Dark Mode with Style Dictionary (part 2)
- Implementing Multi-Brand Theming with Style Dictionary
- Creating Multiple Themes with Style Dictionary
- Creating Sass-backed CSS Custom Properties With Style Dictionary
- Creating a Penpot Design Tokens Format with Style Dictionary
- 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
{"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
{"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
{"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
{"font": {"weight": {"200": {"$value": "200","$type": "fontWeight"},"500": {"$value": "500","$type": "fontWeight"},"700": {"$value": "700","$type": "fontWeight"}}}}
Font Family
{"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.
import StyleDictionary from 'style-dictionary';// Register custom format for spacing utilitiesStyleDictionary.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 utilitiesStyleDictionary.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 utilitiesStyleDictionary.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 utilitiesStyleDictionary.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 utilitiesStyleDictionary.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 configurationconst 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:
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.
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:
format: function({ dictionary }) {// Define what we want to generateconst utilities = [{ prefix: 'text', property: 'color' },{ prefix: 'background', property: 'background-color' },{ prefix: 'border', property: 'border-color' }];// Start building the CSS output as a stringlet output = '/* Colour Utilities */\n\n';// Loop through each utility type (text, background, border)utilities.forEach(util => {// Access all tokens and filter to only colour tokensdictionary.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 ruleoutput += `.${className} {\n ${util.property}: ${token.$value};\n}\n\n`;});});// Return the complete CSS stringreturn 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:
/* 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
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:
import StyleDictionary from 'style-dictionary';import { utilityConfig } from './config/utilities.js';// Register custom format for spacing utilitiesStyleDictionary.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 utilitiesStyleDictionary.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 configurationconst 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.
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
import { utilityConfig } from './config/utilities.js';StyleDictionary.registerFormat({name: 'css/utility-color',format: function({ dictionary }) {// No utilities array defined hereutilityConfig.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
/* 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
/* 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
/* 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:
{"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:
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:
.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.