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 12)

Creating a Penpot Design Tokens Format with 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 – You are here
On This Page:

In the last article, I delved into Penpot's new Design Tokens feature, a promising development that (being a very early implementation) had its share of limitations.

Today, in the Design Tokens Workflow Series, I am going to walk through creating a build script in Style Dictionary that will generate a Penpot-ready JSON file with all our design tokens.

In Penpot's implementation, design tokens are created and managed through a UI. The tokens can be grouped into "Token Sets" and "Token Themes", offering a flexible hierarchical structure. You can learn more about Penpot's Design Tokens from their ~official documentation~.

Creating Token Examples in JSON Format

To illustrate the process of creating Penpot-ready .json, let's use a few (crude) design token examples.

Core Tokens

We start with our core tokens, specifically our colour tokens. These tokens will be stored in a file named ⁠core/01-base/color.tokens to ensure they are imported first by Penpot.

Code languagejson
{
"blue": {
"100": {
"$value": "#cce4f6",
"$type": "color"
},
"200": {
"$value": "#b3d4ee",
"$type": "color"
}
},
"red": {
"100": {
"$value": "#ffe5e5",
"$type": "color"
},
"200": {
"$value": "#ffcccc",
"$type": "color"
}
}
}

Semantic Tokens

Next, we have our semantic tokens, which will override our base tokens where required. Let's create two files for this, ⁠semantic/01-base/color.tokens.json and ⁠semantic/02-brand-a/color.tokens.json. These files will contain semantic color tokens for our base theme and a specific brand, respectively.

Here's an example for ⁠semantic/01-brand-a/color.tokens:

Code languagejson
{
"background": {
"default": {
"$value": "{color.blue.100.value}",
"$type": "color"
},
"muted": {
"$value": "{color.blue.200.value}",
"$type": "color"
}
},
"primary": {
"default": {
"$value": "{color.blue.100.value}",
"$type": "color"
},
"hover": {
"$value": "{color.blue.200.value}",
"$type": "color"
}
}
}

And for semantic/02-brand-b/color.tokens:

Code languagejson
{
"background": {
"default": {
"$value": "{color.red.100.value}",
"$type": "color"
},
"muted": {
"$value": "{color.red.200.value}",
"$type": "color"
}
},
"primary": {
"default": {
"$value": "{color.red.100.value}",
"$type": "color"
},
"hover": {
"$value": "{color.red.200.value}",
"$type": "color"
}
}
}

An Importance for Numbering Folders

In the above examples, you can see that the folders storing our token files are prefixed with a number (01, 02). This numbering serves a vital purpose: it determines the import order in Penpot.

In Penpot, the order of tokens matters because of the way it implements the cascade (like the cascade in CSS. A later token can overwrite a previous one if they have the same name. By numbering our folders, we can ensure that our tokens are imported in the correct order, thus preserving our desired cascade.

Furthermore, this numbering system also helps to keep our design token files organised and easy to navigate, especially in larger projects with many token files. The visual hierarchy provided by the numbering can make it easier to understand the relationships and dependencies between different tokens.

A Note On Penpots Propietary .json Structure

Before we proceed with configuring Style Dictionary, we should take a look at the proprietary json structure that Penpot uses for its tokens. This structure allows Penpot to manage token sets and themes effectively. In Penpot, you can group design tokens into distinct sets, apply them across multiple themes, and control their cascading order. The mechanism behind this is a special structure in the JSON file that includes the ⁠$themes array and the ⁠$metadata object.

Code languagejson
"$themes": [
{
"name": "default",
"selectedTokenSets": {
"base": "enabled",
"default": "enabled"
}
},
{
"name": "brand a",
"selectedTokenSets": {
"base": "enabled",
"brand-a": "enabled"
}
}
],
"$metadata": {
"tokenSetOrder": [
"base",
"default",
"brand-a"
],
"activeThemes": [
"/default",
"/brand a"
],
"activeSets": [
"base",
"default",
"brand-a"
]
}

Let's break this structure down:

This structure provides a flexible and powerful way to manage design tokens in Penpot, enabling you to maintain consistency across different themes and platforms so we need to make sure our Style Dictionary build script creates the output similar to this too.

Configuring Style Dictionary for Penpot

To turn these design tokens into a format that Penpot can read we need to create a custom format and a helper function in Style Dictionary. Here is the complete code we need. I will break it down further next:

Code languagejavascript
import StyleDictionary from 'style-dictionary';
import fs from 'fs';
import path from 'path';
import { globSync } from 'glob';
const getThemesAndMetadata = () => {
const coreFolders = globSync('src/tokens/core/*');
const semanticFolders = globSync('src/tokens/semantic/*');
const themes = [];
const tokenSetOrder = [];
// Include core folders in the token set order
const sortedCoreFolders = coreFolders.sort((a, b) => {
const aPrefix = parseInt(path.basename(a).split('-')[0], 10) || 0;
const bPrefix = parseInt(path.basename(b).split('-')[0], 10) || 0;
return aPrefix - bPrefix;
});
sortedCoreFolders.forEach(folder => {
const folderName = path.basename(folder).replace(/^\d+-/, ''); // Remove numeric prefix
tokenSetOrder.push(folderName);
});
// Include semantic folders in the token set order and themes
const sortedSemanticFolders = semanticFolders.sort((a, b) => {
const aPrefix = parseInt(path.basename(a).split('-')[0], 10) || 0;
const bPrefix = parseInt(path.basename(b).split('-')[0], 10) || 0;
return aPrefix - bPrefix;
});
sortedSemanticFolders.forEach(folder => {
const folderName = path.basename(folder).replace(/^\d+-/, ''); // Remove numeric prefix
tokenSetOrder.push(folderName);
if (folderName !== 'base') {
themes.push({
name: folderName.replace(/-/g, ' '),
selectedTokenSets: {
base: 'enabled',
[folderName]: 'enabled'
}
});
}
});
return { themes, metadata: {
"$metadata": {
"tokenSetOrder": tokenSetOrder,
"activeThemes": themes.map(theme => `/${theme.name}`),
"activeSets": tokenSetOrder
}
}};
};
StyleDictionary.registerFormat({
name: 'json/penpot',
format: async function ({ dictionary }) {
const simplifyTokens = (tokens) => {
const result = {};
Object.entries(tokens).forEach(([key, token]) => {
if (token.$value !== undefined) {
result[key] = {
$value: token.$value,
$type: token.$type
};
} else if (typeof token === 'object') {
result[key] = simplifyTokens(token);
}
});
return result;
};
const { themes, metadata } = getThemesAndMetadata();
const semanticTokens = simplifyTokens(dictionary.tokens);
return JSON.stringify({ ...semanticTokens, "$themes": themes, ...metadata }, null, 2);
}
});
// Find all token files matching the pattern
const tokenFiles = globSync('src/tokens/**/*.tokens');
// Configure Style Dictionary instance
const myStyleDictionary = new StyleDictionary({
source: tokenFiles,
platforms: {
json_combined: {
buildPath: 'build/',
files: [{
destination: 'penpot.json',
format: 'json/penpot',
options: {
outputReferences: true,
nesting: {
global: 'src/tokens/base/**/*.tokens',
semantic: 'src/tokens/semantic/**/*.tokens'
}
}
}],
},
}
});
// Ensure the build directory exists
const buildDir = path.resolve('build');
if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir);
}
// Execute the build process for the json_combined platform
(async () => {
await myStyleDictionary.buildAllPlatforms();
console.log('Build completed!');
})();

Custom Format

We register a custom format with the ⁠StyleDictionary.registerFormat method. This custom format, named json/penpot, contains an asynchronous ⁠format function that simplifies the tokens. It also uses the ⁠getThemesAndMetadata function to generate themes and metadata. The function then combines these into a single Penpot-friendly JSON string.

Code languagejavascript
StyleDictionary.registerFormat({
name: 'json/penpot',
format: async function ({ dictionary }) {
const simplifyTokens = (tokens) => {
const result = {};
Object.entries(tokens).forEach(([key, token]) => {
if (token.$value !== undefined) {
result[key] = {
$value: token.$value,
$type: token.$type
};
} else if (typeof token === 'object') {
result[key] = simplifyTokens(token);
}
});
return result;
};
const { themes, metadata } = getThemesAndMetadata();
const semanticTokens = simplifyTokens(dictionary.tokens);
return JSON.stringify({ ...semanticTokens, "$themes": themes, ...metadata }, null, 2);
}
});

Helper Function

The ⁠getThemesAndMetadata function is a (rather large) helper function that retrieves the sorted list of core and semantic token directories, generates themes for each semantic directory (except 'base'), and sets the order of token sets import for Penpot.

  1. Getting the List of Folders The function begins by retrieving a list of all core and semantic token directories.
Code languagejavascript
const coreFolders = globSync('src/tokens/core/*');
const semanticFolders = globSync('src/tokens/semantic/*');
  1. Initialising the Themes and Token Sets The ⁠themes and ⁠tokenSetOrder arrays are initialised. They will store the themes and the order of token sets for Penpot.
Code languagejavascript
const themes = [];
const tokenSetOrder = [];
  1. Sorting and Processing Core Folders The core folders are sorted numerically based on their prefixes and added to the ⁠tokenSetOrder array.
Code languagejavascript
const sortedCoreFolders = coreFolders.sort((a, b) => {
const aPrefix = parseInt(path.basename(a).split['-'](0), 10) || 0;
const bPrefix = parseInt(path.basename(b).split['-'](0), 10) || 0;
return aPrefix - bPrefix;
});
sortedCoreFolders.forEach(folder => {
const folderName = path.basename(folder).replace(/^\d+-/, ''); // Remove numeric prefix
tokenSetOrder.push(folderName);
});
  1. Sorting and Processing Semantic Folders The semantic folders are also sorted numerically based on their prefixes. The folder names are added to the ⁠tokenSetOrder array. If a folder name is not 'base', a theme is created for it and added to the ⁠themes array.
Code languagejavascript
const sortedSemanticFolders = semanticFolders.sort((a, b) => {
const aPrefix = parseInt(path.basename(a).split['-'](0), 10) || 0;
const bPrefix = parseInt(path.basename(b).split['-'](0), 10) || 0;
return aPrefix - bPrefix;
});
sortedSemanticFolders.forEach(folder => {
const folderName = path.basename(folder).replace(/^\d+-/, ''); // Remove numeric prefix
tokenSetOrder.push(folderName);
if (folderName !== 'base') {
themes.push({
name: folderName.replace(/-/g, ' '),
selectedTokenSets: {
base: 'enabled',
[folderName]: 'enabled'
}
});
}
});
  1. Returning Themes and Metadata Finally, the function returns an object containing the ⁠themes and ⁠metadata for Penpot.
Code languagejavascript
return {
themes,
metadata: {
"$metadata": {
"tokenSetOrder": tokenSetOrder,
"activeThemes": themes.map(theme => `/${theme.name}`),
"activeSets": tokenSetOrder
}
}
};

Style Dictionary Configuration

We configure Style Dictionary to use the custom format for building the tokens. The token files are sourced from the 'src/tokens' directory. The output is a 'penpot.json' file built in the 'build' directory using our 'json/dtcg' custom format.

Code languagejavascript
const myStyleDictionary = new StyleDictionary({
source: tokenFiles,
platforms: {
json_combined: {
buildPath: 'build/',
files: [{
destination: 'penpot.json',
format: 'json/dtcg',
options: {
outputReferences: true,
nesting: {
global: 'src/tokens/base/**/*.tokens',
semantic: 'src/tokens/semantic/**/*.tokens'
}
}
}],
},
}
});

Build Execution

Finally, we ensure the build directory exists, and then run the build process for the ⁠json_combined platform, which generates our Penpot-ready design tokens file.

Code languagejavascript
const buildDir = path.resolve('build');
if (!fs.existsSync(buildDir)) {
fs.mkdirSync(buildDir);
}
(async () => {
await myStyleDictionary.buildAllPlatforms();
console.log('Build completed!');
})();

The Penpot-ready .json Generated Example

After running the build script, we should have a ⁠penpot.json file in our ⁠build directory with all our design tokens. The output should look similar to this:

Code languagejson
{
"blue": {
"100": {
"$value": "#cce4f6",
"$type": "color"
},
"200": {
"$value": "#b3d4ee",
"$type": "color"
}
},
"red": {
"100": {
"$value": "#ffe5e5",
"$type": "color"
},
"200": {
"$value": "#ffcccc",
"$type": "color"
}
},
"background": {
"default": {
"$value": "{color.blue.100.value}",
"$type": "color"
},
"muted": {
"$value": "{color.blue.200.value}",
"$type": "color"
}
},
"primary": {
"default": {
"$value": "{color.blue.100.value}",
"$type": "color"
},
"hover": {
"$value": "{color.blue.200.value}",
"$type": "color"
}
},
"$themes": [
{
"name": "default",
"selectedTokenSets": {
"base": "enabled",
"default": "enabled"
}
},
{
"name": "brand a",
"selectedTokenSets": {
"base": "enabled",
"brand-a": "enabled"
}
}
],
"$metadata": {
"tokenSetOrder": [
"base",
"default",
"brand-a"
],
"activeThemes": [
"/default",
"/brand a"
],
"activeSets": [
"base",
"default",
"brand-a"
]
}
}

Wrapping Up

I’ve walked through how to create a build script using Style Dictionary to generate a Penpot-ready .json file with all your existing design tokens. This is helpful if your ‘source of truth’ for your Design Tokens is somewhere else and Penpot is one more destination for their use. The complete code and examples for this article can be found in the GitHub repository.

As a front-end developer and a Design Systems consultant I feel I’m in a perpetual pendulum of where I think your design tokens source of truth should live. However, like most things, it kind of depends on your team, your products and your workflows. This is an option if you’re design tokens are in code, or if you’re moving towards Penpot as your design tool.

Regardless of where you are on your design systems journey, remember: keep iterating, keep improving, and keep exploring the tools at your disposal, just like I have done with Penpot.

Struggling to create documentation that clearly explains token usage for all team members?

I’ll build comprehensive yet simple documentation for your design tokens.

get in touch!