Building a design token pipeline that keeps component styles isolated and supports light-dark color schemes requires a balance in automation and developer experience. This post demonstrates a Style Dictionary approach that exports JavaScript tokens for Lit while maintaining the flexibility of CSS.

Pipeline structure and requirements

The Style Dictionary build transforms styles from design tokens in JSON format through to code for use in Lit web components. The library comes with built in formats to generate tokens for different platforms but there doesn't yet seem to be a standard approach for combining light and dark color schemes into a single set of tokens that use the light-dark CSS function.

It is possible to create a system that automates the build process to use this modern CSS feature thanks to the flexible API that Style Dictionary provides. Here's one approach that combines CSS and JavaScript to provide CSS tokens for use in Lit components.

Consider the following requirements:

Design token architecture

For this example the design tokens are organized into three tiers. The first two tiers contain primitive and semantic tokens, while the third tier contains component tokens. The build process bundles the first two layers into a root CSS file for inclusion in the app root and bundles component tokens separately for their respective component modules.

Here's a basic example showing how the three token tiers work together to apply a background color to a button. In most cases the light and dark variants of a token exist in the theme tier but they may also appear in components.

{
  "color": {
    "$type": "color",
    "$description": "This is the color palette definitions",
    "brand": {
      "green": {
        "$value": "#00ff00"
      }
    }
  },

  "theme": {
    "color": {
      "$type": "color",
      "$description": "Theme color tokens alias the definitions",
      "primary": {
        "background": {
          "$value": "{color.brand.green}"
        }
      }
    }
  },

  "button": {
    "$description": "Component tokens for the button element reference the theme",
    "primary": {
      "background": {
        "$type": "color",
        "$value": "{theme.color.primary.background}"
      }
    }
  }
}

Building these tokens with Style Dictionary using the default format for CSS variables using output references, the generated output looks like this:

:root {
  --color-brand-green: #00ff00;
  --theme-color-primary-background: var(--color-brand-green);
  --button-primary-background: var(--theme-color-primary-background);
}

Isolating component tokens

Isolating the component tokens means they only get bundled or loaded when using that component within an app. While tools exist to remove unused CSS during the bundling phase, keeping component styles local to their module or package helps reduce overhead and provides better separation of concerns.

To achieve this isolation the Style Dictionary build configuration needs to respect the three token tiers. Filtering the component properties out of the root style sheet requires a function to include only the tokens in those tiers.

This example uses the file system path, but this approach could apply to any token attribute, including extensions. The components exist in a separate directory, and a path check removes them from the root variables file:

import StyleDictionary from "style-dictionary";

const sd = new StyleDictionary({
  source: ["definitions/**/*.json", "theme/**/*.json", "components/**/*.json"],
  platforms: {
    css: {
      transformGroup: "css",
      buildPath: `dist/`,
      files: [
        {
          destination: "variables.css",
          format: "css/variables",
          options: {
            outputReferences: true,
          },
          // Filter out the components using the token file path
          filter: (token) => !token.filePath.includes("components"),
        },
      ],
    },
  },
});

Applying tokens to components

Lit recommends using the static style prop for component styles to achieve the best performance. Generating properties in CSS format doesn't align with this recommendation and lacks a strong link between the token and the component implementation.

Generating tokens in ECMAScript module format for use with Lit require the unsafeCSS function and prevents customization through CSS properties.

import { LitElement, css, unsafeCSS } from "lit";
import { primaryBackground } from "styles/button.js";

export class Button extends LitElement {
  // Background color is generated as the raw color value #00ff00
  static styles = css`
    button {
      background-color: ${unsafeCSS(primaryBackground)};
    }
  `;
}

A better approach is generating button properties as CSS string exports from an ECMAScript module. This creates a strong link to the token while keeping CSS as the source of truth for styling.

Adding a custom format for Style Dictionary creates an export of all CSS properties with individual exports for use in style declarations. The format wraps each export with the css template string tag for use with Lit.

import StyleDictionary from "style-dictionary";
import { propertyFormatNames, transforms } from "style-dictionary/enums";
import { formattedVariables } from "style-dictionary/utils";

StyleDictionary.registerFormat({
  name: "javascript/litCSS",
  format: ({ dictionary, options }) => {
    return [
      `import { css } from 'lit';`,
      // Generate all properties for the host element
      `export const props = css\`:host {\n${formattedVariables({
        format: propertyFormatNames.css,
        dictionary,
        outputReferences: true,
        usesDtcg: true,
      })}\n}\`;`,
      // Export js/CSS variable references for each property
      dictionary.allTokens.map((token) => {
        // Remove the component name from the JS variable for cleaner exports
        const [, ...path] = token.path;
        const nameCamel = StyleDictionary.hooks.transforms[
          transforms.nameCamel
        ].transform({ ...token, path }, options);
        const nameKebab = StyleDictionary.hooks.transforms[
          transforms.nameKebab
        ].transform(token, options);

        return `export const ${nameCamel} = css\`var(--${nameKebab})\`;`;
      }),
    ].join("\n\n");
  },
});

Save the files as JavaScript or TypeScript resulting in a module like this for the button element.

import { css } from "lit";

// The props export contains the host selector with all of the component properties
export const props = css`
  :host {
    --button-primary-background: var(--theme-color-primary-background);
  }
`;

// Individual token exports
export const primaryBackground = css`var(--button-primary-background)`;

In the component, add the props to the component styles and reference them in the component styles implementation:

import { props, primaryBackground } from "./styles/button.js";

export class Button extends LitElement {
  static styles = [
    props,
    css`
      .primary {
        background-color: ${primaryBackground};
      }
    `,
  ];
}

To override the component styles, set the property value at the element.

my-button {
  --button-primary-background: red;
}

To provide a better developer experience, the format can provide the component with unimplemented properties with an alias as the default value.

import { css } from "lit";

// --button-primary-background is open for implementation
export const props = css`
  :host {
    --primary-background: var(
      --button-primary-background,
      var(--theme-color-primary-background)
    );
  }
`;

export const primaryBackground = css`var(--primary-background)`;

This makes it possible to override the properties at other levels, such as the app root.

:root {
  --button-primary-background: red;
}

Using the light-dark function for color schemes

To create light and dark themes, each token requires two different values. A few approaches exist to structure tokens for different color schemes, but currently no standardized approach exists in the token specification or Style Dictionary.

Exporting tokens from design tools like Figma or Tokens Studio tends to result in a full set of tokens for each mode at the theme and component tiers. Processing these tokens with Style Dictionary requires two independent builds to produce separate style sheets: one for light and one for dark.

To combine the separate style sheets into one with the light-dark syntax requires some post-processing.

Here's a Style Dictionary build script that creates the separate builds:

// The list of components might come from a configuration file or a
// glob of the token JSON files
const components = ["button"];

for (const mode of ["light", "dark"]) {
  const sd = new StyleDictionary({
    source: [
      "definitions/**/*.json",
      `theme/${mode}/**/*.json`,
      `components/${mode}/**/*.json`,
    ],
    platforms: {
      css: {
        transformGroup: "css",
        buildPath: `dist/${mode}/`,
        files: [
          {
            destination: "variables.css",
            format: "css/variables",
            options: {
              outputReferences: true,
            },
            // Filter out the components using the file path
            filter: (token) => !token.filePath.includes("components"),
          },
          // Build the JavaScript Lit CSS exports for each component as .ts
          ...components.map((component) => ({
            destination: `${component}.ts`,
            format: "javascript/litCSS",
            options: {
              outputReferences: true,
            },
            filter: (token) => token.filePath.includes(component),
          })),
        ],
      },
    },
  });

  await sd.buildAllPlatforms();
}

This approach creates two directories, one for light and one for dark. Using pattern matching in a post build script merges the tokens with light and dark values into the light-dark function syntax to create a new variables style sheet.

const light = await fs.readFile("dist/light/variables.css", "utf-8");
const dark = await fs.readFile("dist/dark/variables.css", "utf-8");

// Matches css properties like --them-color-primary-background
const propertiesPattern = /(--[^:]+?):\s*([^;]+?);/g;
const lightDark = {};

// Collect all light tokens
light.matchAll(propertiesPattern).forEach(([, prop, value]) => {
  lightDark[prop] = value;
});

// Collect all dark tokens updating any existing light values
// that differ into the light-dark() syntax
dark.matchAll(propertiesPattern).forEach(([, prop, value]) => {
  if (!lightDark[prop]) {
    lightDark[prop] = value;
  } else if (lightDark[prop] !== value) {
    lightDark[prop] = `light-dark(${lightDark[prop]}, ${value})`;
  }
});

// Create a new variables style sheet
const content = `:root {
${Object.entries(lightDark)
  .map(([prop, value]) => `  ${prop}: ${value};`)
  .join("\n")}
}`;
await fs.writeFile("dist/variables.css", content, "utf-8");

Applying a similar approach to the ECMAScript files for each component results in a single set of exports that works for both color schemes.

Build failures deliver fast feedback

Feedback from visual regression testing requires more time, tooling, and potential cost overhead than build pipeline failures that deliver more immediate feedback to developers.

This approach provides fast feedback and offers other benefits, such as flexibility in property naming (including changing the token prefix) and preventing unused or unimplemented variables from accumulating in the codebase.

Automating CSS property documentation

The custom elements manifest analyzer generates component documentation using a framework plugin for Lit. To include CSS properties in the documentation requires @cssproperty JS Doc tags for each property but instead of adding these manually an analyzer plugin can add them during the pacakgeLinkPhase. The plugin requires the component tokens in JavaScript format and maps the properties to each entry in the manifest.

import { cssPropertiesPlugin } from "@lime-soda/cem-plugin-css-properties";
import tokens from "./design-tokens.js";

// custom-elements-manifest.config.js
export default {
  globs: ["src/components/**/*.js"],
  outdir: "dist",
  LitElement: true,
  plugins: [cssPropertiesPlugin(tokens, { prefix: "my" })],
};

The resulting manifest file contains the property information for each component.

{
  "schemaVersion": "1.0.0",
  "modules": [
    {
      "kind": "javascript-module",
      "path": "src/Button.ts",
      "declarations": [
        {
          "kind": "class",
          "description": "Button element.",
          "name": "Button",
          "superclass": {
            "name": "LitElement",
            "package": "lit"
          },
          "tagName": "my-button",
          "customElement": true,
          "cssProperties": [
            {
              "name": "--button-primary-background",
              "description": "Primary button background color",
              "default": "#00ff00"
            }
          ]
        }
      ],
      "exports": [
        {
          "kind": "js",
          "name": "Button",
          "declaration": {
            "name": "Button",
            "module": "src/Button.ts"
          }
        },
        {
          "kind": "custom-element-definition",
          "name": "my-button",
          "declaration": {
            "name": "Button",
            "module": "src/Button.ts"
          }
        }
      ]
    }
  ]
}

Check out this brief example for a working example.