A Design Tokens Workflow (part 12)
Creating a Penpot Design Tokens Format with 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 – 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.
{"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
:
{"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
:
{"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.
"$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:
$themes
: This is an array of theme objects. Each theme object must have aname
and aselectedTokenSets
object. The `selectedTokenSets object should list all token sets that are enabled for that theme.-
$metadata
: This object contains three properties:tokenSetOrder
,activeThemes
, andactiveSets
.tokenSetOrder
: An array that defines the order in which token sets are imported into Penpot. This is vital for controlling the cascade of tokens.-
activeThemes
: An array that lists the themes currently active in the Penpot interface. -
activeSets
: An array that indicates the token sets active in the Penpot interface.
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:
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 orderconst 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 prefixtokenSetOrder.push(folderName);});// Include semantic folders in the token set order and themesconst 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 prefixtokenSetOrder.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 patternconst tokenFiles = globSync('src/tokens/**/*.tokens');// Configure Style Dictionary instanceconst 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 existsconst 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.
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.
- Getting the List of Folders The function begins by retrieving a list of all core and semantic token directories.
const coreFolders = globSync('src/tokens/core/*');const semanticFolders = globSync('src/tokens/semantic/*');
- 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.
const themes = [];const tokenSetOrder = [];
- Sorting and Processing Core Folders
The core folders are sorted numerically based on their prefixes and added to the
tokenSetOrder
array.
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 prefixtokenSetOrder.push(folderName);});
- 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 thethemes
array.
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 prefixtokenSetOrder.push(folderName);if (folderName !== 'base') {themes.push({name: folderName.replace(/-/g, ' '),selectedTokenSets: {base: 'enabled',[folderName]: 'enabled'}});}});
- Returning Themes and Metadata
Finally, the function returns an object containing the
themes
andmetadata
for Penpot.
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.
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.
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:
{"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.