Namespacing tokens prevents collisions with external systems
- Published • 5 min
Introduction
Tokens or generally CSS variables from external systems can silently override your app tokens. Here’s’ the short story of how to keep your tokens safe.
Astro is a great blog engine which enables you to use Vue.js or React components seamlessly. I’ve built a React component and wanted to use Tailwind for it. It turned out that my --color-red-300 has been replaced with the value from Tailwind --color-red-300.
// Shortened for simplicity
---
import AComponent from "@components/AComponent/AComponent";
---
<div class="flex flex-col">
<AComponent client:load />
</div>
<AComponent /> is importing Tailwind stylesheets:
/* src/components/AComponent/AComponent.css */
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
It was barely noticeable, but a single @import "tailwindcss/theme.css"; did that:
It happened because Tailwind theme.css defines
--color-red-300: oklch(80.8% 0.114 19.571) whereas app token was: --color-pomegranate-300: oklch(0.56 0.19 25.68);
/* packages/tailwindcss/theme.css */
:root {
--color-red-50: oklch(97.1% 0.013 17.38);
--color-red-100: oklch(93.6% 0.032 17.717);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-red-800: oklch(44.4% 0.177 26.899);
--color-red-900: oklch(39.6% 0.141 25.723);
--color-red-950: oklch(25.8% 0.092 26.042);
}
As it silently replaces values, it could theoretically replace more without me noticing.
Namespace-first attempt
It just failed.
I asked Claude. I wanted to add just a prefix, but the way Claude conducted that disappointed me as it was trying to inject into the Astro hook and via PostCSS replace app tokens by adding --ryrych- prefix. As it took forever I quit.
Follow your intuition or experience first, instead of lazily following hallucinations. Yeah, facepalm, it happens to me sometimes.
Namespace-second attempt
Having a sub-package is a good idea. Until you need to publish it, just have it locally. And this is what I wanted. Sorry Claude for suggesting, I knew it better. Just do it.
src/packages/design-tokens/build.mjs was a good approach. Just moving all variables defined in :root {…} into a package:
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import postcss from "postcss";
const packageDir = path.dirname(fileURLToPath(import.meta.url));
const srcDir = path.join(packageDir, "src");
const outFile = path.join(packageDir, "dist", "index.css");
const prefix = process.env.TOKENS_PREFIX || "ryrych";
function prefixDecl(decl) {
decl.prop = `--${prefix}-${decl.prop.slice(2)}`;
decl.value = decl.value.replaceAll("var(--", `var(--${prefix}-`);
}
const sourceFiles = fs
.readdirSync(srcDir)
.filter((file) => file.endsWith(".css"))
.sort();
const built = sourceFiles
.map((file) => {
const sourcePath = path.join(srcDir, file);
const root = postcss.parse(fs.readFileSync(sourcePath, "utf8"), {
from: sourcePath,
});
root.walkRules(":root", (rule) => rule.walkDecls(/^--/, prefixDecl));
return `/* ${file} */\n${root.toString().trim()}`;
})
.join("\n\n");
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(
outFile,
`/**\n * AUTO-GENERATED by \`npm run build\` — do not edit directly.\n * Edit the source files in ./src instead.\n */\n\n${built}\n`,
);
console.log(
`built ${path.relative(process.cwd(), outFile)} (prefix: ${prefix})`,
);
It worked.
Namespace-third attempt
It kind of made me dissatisfied. I was writing about Style Dictionary (in Polish) and wanted to use it. It is opinionated stuff and the migration went smoothly:
import path from "node:path";
import { fileURLToPath } from "node:url";
import StyleDictionary from "style-dictionary";
const packageDir = path.dirname(fileURLToPath(import.meta.url));
const buildPath = `${path.join(packageDir, "dist")}/`; // must end with a trailing slash
const prefix = process.env.TOKENS_PREFIX || "ryrych";
const sd = new StyleDictionary({
source: [
path.join(packageDir, "tokens/colors.json"),
path.join(packageDir, "tokens/theme.json"),
path.join(packageDir, "tokens/typography.json"),
],
log: { verbosity: "silent" },
platforms: {
css: {
transformGroup: "css",
prefix,
buildPath,
files: [
{
destination: "index.css",
format: "css/variables",
options: {
outputReferences: true,
fileHeader: () => [
"AUTO-GENERATED by `npm run build` — do not edit directly.",
"Edit the source files in ./tokens instead.",
],
},
},
],
},
},
});
await sd.buildAllPlatforms();
const outFile = path.join(buildPath, "index.css");
console.log(
`built ${path.relative(process.cwd(), outFile)} (prefix: ${prefix})`,
);
I used workspaces:
{
"workspaces": ["packages/*"]
}
Tokens are build with npm run build:tokens
{
"scripts": {
"build:tokens": "npm run build --prefix packages/design-tokens"
}
}
Tokens defined this way:
// packages/design-tokens/tokens/colors.json
{
"color": {
"pomegranate": {
"100": { "value": "oklch(0.85 0.19 25.68)" },
"200": { "value": "oklch(0.7 0.19 25.68)" },
"300": { "value": "oklch(0.56 0.19 25.68)" },
"400": { "value": "oklch(0.37 0.19 25.68)" },
"500": { "value": "oklch(0.19 0.19 25.68)" }
},
"lime": {
"100": { "value": "oklch(0.92 0.26 142.5)" },
"200": { "value": "oklch(0.84 0.26 142.5)" },
"300": { "value": "oklch(0.76 0.26 142.5)" },
"400": { "value": "oklch(0.5 0.26 142.5)" },
"500": { "value": "oklch(0.25 0.26 142.5)" }
},
"black": { "value": "#000000" },
"white": { "value": "#ffffff" },
"accent": { "value": "#c2feff" },
"red": {
"100": { "value": "{color.pomegranate.100}" },
"200": { "value": "{color.pomegranate.200}" },
"300": { "value": "{color.pomegranate.300}" },
"400": { "value": "{color.pomegranate.400}" },
"500": { "value": "{color.pomegranate.500}" }
}
}
}
Lands in dist/index.css exactly the same way until I had problems with Tailwind.
/* packages/design-tokens/dist/index.css */
:root {
--ryrych-color-pomegranate-100: oklch(0.85 0.19 25.68);
--ryrych-color-pomegranate-200: oklch(0.7 0.19 25.68);
--ryrych-color-pomegranate-300: oklch(0.56 0.19 25.68);
--ryrych-color-pomegranate-400: oklch(0.37 0.19 25.68);
--ryrych-color-pomegranate-500: oklch(0.19 0.19 25.68);
--ryrych-color-lime-100: oklch(0.92 0.26 142.5);
--ryrych-color-lime-200: oklch(0.84 0.26 142.5);
--ryrych-color-lime-300: oklch(0.76 0.26 142.5);
--ryrych-color-lime-400: oklch(0.5 0.26 142.5);
--ryrych-color-lime-500: oklch(0.25 0.26 142.5);
--ryrych-color-black: #000000;
--ryrych-color-white: #ffffff;
--ryrych-color-accent: #c2feff;
--ryrych-background: #1e1e2e;
--ryrych-background-light: #fcebf3;
--ryrych-text: #eff1f5;
--ryrych-text-light: #1e1e2e;
--ryrych-text-link: #78c2ad;
--ryrych-text-link-light: #375a7f;
--ryrych-underline: #375a7f;
--ryrych-border-radius-full: calc(infinity * 1px);
--ryrych-header: "Lato", sans-serif;
--ryrych-body: "Fira Code", monospace;
--ryrych-color-red-100: var(--ryrych-color-pomegranate-100);
--ryrych-color-red-200: var(--ryrych-color-pomegranate-200);
--ryrych-color-red-300: var(--ryrych-color-pomegranate-300);
--ryrych-color-red-400: var(--ryrych-color-pomegranate-400);
--ryrych-color-red-500: var(--ryrych-color-pomegranate-500);
}
Summary
I like to have control over naming conventions. When you are integrating external packages like Tailwind you never know what external code might silently break. Adding a namespace to your tokens can minimize problems. A few weeks ago a GitHub. workflow responsible for screenshot testing was hanging. It was a GitHub. issue, effectively preventing me from creating a new releases. I temporarily removed it. I think it’s time to bring screenshot testing with Playwright back. My eyes might not be enough to spot silent bugs.