Available For Front-End & Design Systems Contracts – Get In Touch

Always Twisted

A Design Tokens Workflow (part 15)

Managing Microcopy with Design Tokens and 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
  14. Using Notion With Style Dictionary for Design Tokens Management
  15. Managing Microcopy with Design Tokens – You are here
On This Page:

When we talk about design tokens, we usually mean colours, spacing, typography, and other visual properties. But there's another layer of design that's critical and rarely managed systematically: microcopy.

Microcopy is the small pieces of text scattered throughout your interface: button labels, form hints, error messages, empty states, confirmation dialogs. While we version control colour palettes and spacing scales, interface copy is commonly hardcoded into components, scattered across files, and inconsistently managed across platforms.

I was first introduced to microcopy in web design by Relly, and later during a workshop Jina ran in 2019, they mentioned how tokens could be a perfect fit for managing microcopy systematically.

These aren't mere strings. An error message that says "Error" versus "Please enter a valid email address" is a deliberate design decision affecting comprehension, trust, and conversion. A button labelled "OK" versus "Save changes" guides user behavior. If you want to follow along with a working example whilst reading this article, the Style Dictionary Starter includes a complete microcopy setup you can explore and experiment with.

The problem is there could be no single source of truth. You can't say "all our primary action buttons should say 'Continue'" without hunting through dozens of files. When you need to update an error message, you would be hunting through codebases rather than updating a token. Designers, copywriters, and product managers can't propose changes without potentially diving into code or visiting every screen to be sure.

But, what if we could manage microcopy the same way we manage colours, spacing, and typography?

Why Manage Microcopy as Tokens?

Managing microcopy in this way can give us several benefits.

First, you get consistency across your entire product. The language doesn't shift from "Save" in one place to "Submit" in another. Accessibility becomes part of the design by default: labels, aria-labels, and descriptions all defined in one place.

For teams supporting multiple languages, localisation becomes straightforward. Instead of managing separate systems with different schemas, you maintain a single token structure with language specific layers. Edit the JSON, rebuild, deploy.

Your product's brand voice comes through consistently across the entire interface, while team collaboration improves because everyone can participate. Every change can flow through git. You can roll back instantly, see who changed what and why, and maintain a complete trail.

Finally, you generate platform specific output from one source. JavaScript objects for web, Swift dictionaries for iOS, XML for Android. One source of truth for every platform.

Understanding the W3C Specification

Here's where it gets a little interesting.

The W3C Design Tokens Community Group specification doesn't currently include a content or string type. The spec defines:

Basic types

Composite types

No content. No string.

Since every token must have a valid $type, we have a specification gap.

How can we add microcopy? We will have to work around this.

We can either inherit a $type from a parent group or acknowledge we're extending the spec beyond what it currently supports. The best practice is to set $type at the group level, your microcopy group can declare a type, and then use $extensions to mark which tokens are actually microcopy.

Remember the article on Understanding $extensions in the Design Tokens Specification?

This is exactly what it's designed for. Adding custom, tool-specific data without breaking the standard. Microcopy is a perfect use case.

Structuring Microcopy Tokens

A well organised microcopy token structure mirrors how developers think about interfaces. Group content by context (forms, navigation, feedback) and component type (button, input, modal), using the same hierarchical approach you'd apply to colours or spacing.

I recommend something like this structure:

tokens/
  copy.tokens.json         # Root file with group-level $type
  copy-button.tokens.json  # Button labels
  copy-form.tokens.json    # Form labels, hints, placeholders
  copy-error.tokens.json   # Validation, system, and UI state messages

This keeps all token files in the same tokens/ folder for simplicity. The root file establishes the group structure, while category files contain the actual token definitions.

Creating Microcopy Tokens With $extensions

The spec requires every token to have a valid $type. Since there's no string type, we set $type at the group level (using a placeholder type like number) and rely on $extensions to identify microcopy tokens. This is a pragmatic workaround to an acknowledged gap in the spec.

Start by creating the root token file tokens/copy.tokens.json:

Code languagejson
{
  "copy": {
    "$type": "number",
    "$description": "Microcopy tokens. Type is set here to satisfy spec requirements; use $extensions to identify actual microcopy tokens.",
    "button": {},
    "form": {},
    "error": {}
  }
}

This root file serves as the group-level container for all microcopy tokens. By setting $type at the top level, all child tokens inherit this type, eliminating the need to repeat $type in every individual tokens.json file. The empty objects ("button": {}, etc.) define the structure without containing actual token values.

Now create button labels in tokens/copy-button.tokens.json:

Code languagejson
{
  "copy": {
    "button": {
      "primary": {
        "label": {
          "$value": "Continue",
          "$description": "Primary action button label",
          "$extensions": {
            "com.alwaystwisted.microcopy": {
              "enabled": true,
              "category": "button",
              "context": "primary-action"
            }
          }
        }
      },
      "secondary": {
        "label": {
          "$value": "Cancel",
          "$extensions": {
            "com.alwaystwisted.microcopy": {
              "enabled": true,
              "category": "button",
              "context": "secondary-action"
            }
          }
        }
      },
      "submit": {
        "label": {
          "$value": "Submit",
          "$extensions": {
            "com.alwaystwisted.microcopy": {
              "enabled": true,
              "category": "button"
            }
          }
        }
      },
      "delete": {
        "label": {
          "$value": "Delete",
          "$extensions": {
            "com.alwaystwisted.microcopy": {
              "enabled": true,
              "category": "button",
              "context": "destructive"
            }
          }
        },
        "confirm": {
          "$value": "Are you sure you want to delete this item?",
          "$extensions": {
            "com.alwaystwisted.microcopy": {
              "enabled": true,
              "category": "button",
              "context": "destructive-confirm"
            }
          }
        }
      }
    }
  }
}

Now form related copy in tokens/copy-form.tokens.json:

Code languagejson
{
  "copy": {
    "form": {
      "input": {
        "email": {
          "label": {
            "$value": "Email address",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          },
          "placeholder": {
            "$value": "[email protected]",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          },
          "hint": {
            "$value": "We'll never share your email with anyone else",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          }
        },
        "password": {
          "label": {
            "$value": "Password",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          },
          "placeholder": {
            "$value": "Enter your password",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          },
          "hint": {
            "$value": "Must be at least 8 characters",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          }
        }
      }
    }
  }
}

Error and UI state messages in tokens/copy-error.tokens.json:

Code languagejson
{
  "copy": {
    "error": {
      "validation": {
        "required": {
          "$value": "This field is required",
          "$extensions": {
            "com.alwaystwisted.microcopy": { "enabled": true }
          }
        },
        "email": {
          "invalid": {
            "$value": "Please enter a valid email address",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          }
        },
        "password": {
          "tooShort": {
            "$value": "Password must be at least 8 characters",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          },
          "mismatch": {
            "$value": "Passwords do not match",
            "$extensions": {
              "com.alwaystwisted.microcopy": { "enabled": true }
            }
          }
        }
      }
    }
  }
}

Generating Microcopy With Style Dictionary

Now that we have our tokens structured with $extensions, we need to get them into our build pipeline. We'll create custom Style Dictionary formats that output JavaScript and JSON from the same source.

Since the DTCG spec doesn't define a string type, we used $type: number as a placeholder at the group level in our token definitions. This allows tokens to be valid according to the spec while we use $extensions to identify actual microcopy. The key is that Style Dictionary can filter based on the extension, which is what matters for our build.

Create a build.js file to build your microcopy tokens:

Code languagejavascript
import StyleDictionary from 'style-dictionary';

// Filter tokens to get only microcopy tokens based on extensions
const getMicrocopyTokens = (dictionary) => {
  return dictionary.allTokens.filter((token) => {
    return (
      token.$extensions &&
      token.$extensions['com.alwaystwisted.microcopy'] &&
      token.$extensions['com.alwaystwisted.microcopy'].enabled
    );
  });
};

// Build a nested object from flat token paths
const buildNestedObject = (tokens) => {
  const result = {};

  tokens.forEach((token) => {
    const path = token.path.slice(1); // Skip 'copy' prefix from path
    let current = result;

    path.forEach((key, index) => {
      if (index === path.length - 1) {
        // Set the value at the final key
        current[key] = token.$value;
      } else {
        // Create nested object if it doesn't exist
        current[key] = current[key] || {};
        current = current[key];
      }
    });
  });

  return result;
};

// Register custom format for JavaScript ES6 export
StyleDictionary.registerFormat({
  name: 'javascript/microcopy',
  format: function ({ dictionary }) {
    const nested = buildNestedObject(getMicrocopyTokens(dictionary));
    return `export const microcopy = ${JSON.stringify(nested, null, 2)};`;
  },
});

// Register custom format for JSON output
StyleDictionary.registerFormat({
  name: 'json/microcopy',
  format: function ({ dictionary }) {
    const nested = buildNestedObject(getMicrocopyTokens(dictionary));
    return JSON.stringify(nested, null, 2);
  },
});

const config = {
  source: ['tokens/**/*.tokens.json'], // Source files for tokens
  platforms: {
    js: {
      transformGroup: 'js', // Use JavaScript transforms
      buildPath: 'build/js/', // Output directory
      files: [
        {
          destination: 'microcopy.js', // Output file
          format: 'javascript/microcopy', // Use custom format
        },
      ],
    },
    json: {
      transformGroup: 'js',
      buildPath: 'build/json/',
      files: [
        {
          destination: 'microcopy.json',
          format: 'json/microcopy',
        },
      ],
    },
  },
};

const sd = new StyleDictionary(config);
await sd.buildAllPlatforms();

Run the build with node build.js and you'll get:

build/
  js/
    microcopy.js
  json/
    microcopy.json

For example, build/js/microcopy.js gives you a nested object:

Code languagejavascript
export const microcopy = {
  button: {
    primary: {
      label: 'Continue',
    },
    secondary: {
      label: 'Cancel',
    },
    submit: {
      label: 'Submit',
    },
    delete: {
      label: 'Delete',
      confirm: 'Are you sure you want to delete this item?',
    },
  },
  // ... ,,,
};

Using Microcopy Tokens in Components

Now you can use this in your components with clean, semantic references.

React Example

Here's a simple sign-up form demonstrating microcopy token usage:

Code languagejsx
import { microcopy } from './build/js/microcopy.js';
import { useState } from 'react';

function SignUpForm() {
  // State for form input
  const [email, setEmail] = useState('');

  return (
    <form>
      {/* Email input using microcopy tokens for label and placeholder */}
      <div className="form-group">
        <label htmlFor="email">{microcopy.form.input.email.label}</label>
        <input
          id="email"
          type="email"
          placeholder={microcopy.form.input.email.placeholder}
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>

      {/* Submit button using microcopy token for text */}
      <button type="submit" className="btn-primary">
        {microcopy.button.submit.label}
      </button>
    </form>
  );
}

This is far more readable than hardcoded strings. Instead of <button>Submit</button>, you have a semantic reference that clearly identifies which piece of text you're using. When a copywriter updates the token, every instance updates automatically.

Vanilla JavaScript Validation

Here's a simple validation function using microcopy:

Code languagejavascript
import { microcopy } from './build/js/microcopy.js';

// Validate email with microcopy messages
function validateEmail(value) {
  if (!value || value.trim() === '') {
    return { valid: false, message: microcopy.error.validation.required };
  }
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(value)) {
    return { valid: false, message: microcopy.error.validation.email.invalid };
  }
  return { valid: true };
}

// Usage
const emailInput = document.querySelector('#email');
const errorElement = document.querySelector('.error-message');

emailInput.addEventListener('blur', (e) => {
  const result = validateEmail(e.target.value);
  errorElement.textContent = result.valid ? '' : result.message;
  errorElement.style.display = result.valid ? 'none' : 'block';
});

The validator is completely decoupled from actual messages. Update the tokens, rebuild, and validation messages update everywhere automatically.

Multi-Language Support Through Token Layers

Design tokens really shine for internationalization. Instead of managing separate i18n files with different structures, use the same token architecture you use for theming.

Create language-specific token files. Set $type at the group level (using our placeholder approach), then override the values:

tokens/copy-en-button.tokens.json

Code languagejson
{
  "copy": {
    "button": {
      "primary": {
        "label": {
          "$value": "Continue",
          "$extensions": { "com.alwaystwisted.microcopy": { "enabled": true } }
        }
      },
      "secondary": {
        "label": {
          "$value": "Cancel",
          "$extensions": { "com.alwaystwisted.microcopy": { "enabled": true } }
        }
      }
    }
  }
}

tokens/copy-fr-button.tokens.json

Code languagejson
{
  "copy": {
    "button": {
      "primary": {
        "label": {
          "$value": "Continuer",
          "$extensions": { "com.alwaystwisted.microcopy": { "enabled": true } }
        }
      },
      "secondary": {
        "label": {
          "$value": "Annuler",
          "$extensions": { "com.alwaystwisted.microcopy": { "enabled": true } }
        }
      }
    }
  }
}

Create a build-i18n.js file for multi-language builds:

Code languagejavascript
import StyleDictionary from 'style-dictionary';

// Register custom format for JavaScript ES6 export (multi-language version)
StyleDictionary.registerFormat({
  name: 'javascript/microcopy',
  format: function ({ dictionary }) {
    // Filter to get only microcopy tokens
    const microcopyTokens = dictionary.allTokens.filter((token) => {
      return (
        token.$extensions &&
        token.$extensions['com.alwaystwisted.microcopy'] &&
        token.$extensions['com.alwaystwisted.microcopy'].enabled
      );
    });

    // Build nested object from token paths
    const buildNestedObject = (tokens) => {
      const result = {};
      tokens.forEach((token) => {
        const path = token.path.slice(1); // Skip 'copy' prefix
        let current = result;
        path.forEach((key, index) => {
          if (index === path.length - 1) {
            current[key] = token.$value;
          } else {
            current[key] = current[key] || {};
            current = current[key];
          }
        });
      });
      return result;
    };

    const nested = buildNestedObject(microcopyTokens);
    return `export const microcopy = ${JSON.stringify(nested, null, 2)};`;
  },
});

// List of supported languages
const languages = ['en', 'fr'];

// Build microcopy for each language
for (const language of languages) {
  const config = {
    source: [`tokens/copy-${language}-*.tokens.json`], // Language-specific token files
    platforms: {
      js: {
        transformGroup: 'js',
        buildPath: `build/js/${language}/`, // Output to language-specific directory
        files: [
          {
            destination: 'microcopy.js',
            format: 'javascript/microcopy',
          },
        ],
      },
    },
  };

  const sd = new StyleDictionary(config);
  await sd.buildAllPlatforms();
}

Run the multi-language build with node build-i18n.js.

This creates:

build/
  js/
    en/microcopy.js
    fr/microcopy.js

With identical structure, just different copy.

Why This Approach Works

Using $extensions for microcopy keeps your tokens valid according to the DTCG spec. Other tools can parse your files without breaking, even if they don't understand your extensions. When the spec evolves, the DTCG is actively discussing it, so you can migrate seamlessly.

More importantly, it treats microcopy as a first-class citizen in your design system. It's not an afterthought. It's not a hack. It's part of your core token infrastructure, managed the same way you manage colours, spacing, and typography. For a complete working implementation, refer to the Style Dictionary Starter repository.

Your interface copy directly impacts how users understand, trust, and succeed with your product. It deserves systematic management, version control, collaboration, and the same attention you give to visual design.

Start small. Tokenise your error messages. Then expand. You'll wonder how you ever managed microcopy any other way.

🦋 - likes

    🔄 - reposts

      Like this post on Bluesky

      Want to ensure your website meets accessibility standards and works for everyone?

      I'll audit and enhance your HTML, CSS, and JavaScript for WCAG compliance and inclusivity.

      get in touch!