Writing Custom Stylelint Rules for Token Usage
Part of Stylelint Plugin Configuration. This page covers the exact task of authoring a PostCSS-based Stylelint rule that flags raw CSS values or unregistered token references before they reach production.
Enforcing consistent design token consumption across large-scale frontends requires moving beyond static configuration. By authoring targeted linting rules, engineering teams can intercept invalid token references at commit time. This architectural shift transforms CSS validation from a passive check into an active governance layer, ensuring that component styling remains strictly bound to the centralized design system registry. If you also need to audit which tokens are unused across your entire repository, see detecting orphaned and unused tokens in CI for the complementary audit-script approach.
plugin/token-usage rule traverses the PostCSS AST, checks each declaration value against the token registry, and reports a violation for raw values while passing registered token references.Prerequisites
- Node.js ≥ 18 and Stylelint ≥ 16 installed as dev dependencies.
- A token registry — a JSON file (e.g.
tokens.json) listing all canonical token names exported from your design tool or token compiler. - Basic familiarity with PostCSS AST nodes:
Declaration,Rule, andAtRule. - A
.stylelintrc.jsonorstylelint.config.jsalready wired into your CI pipeline. - Optional:
postcss-scssorpostcss-lessif your codebase uses a CSS preprocessor, so the custom syntax is declared before the rule runs.
Step-by-Step Implementation: Rule Authoring
Begin by scaffolding a custom plugin using the Stylelint API. Define a rule name following the plugin/ namespace convention to avoid collisions with core rules. Implement the core validation logic using PostCSS AST traversal to inspect value and declaration nodes efficiently. Match token patterns using strict regex boundaries or JSON schema lookups against your design tokens manifest. When violations occur, return a stylelint.utils.report() call that includes precise line/column metadata and contextual messaging.
Step 1 — Create the plugin file at lib/rules/token-usage.js:
// lib/rules/token-usage.js
const stylelint = require('stylelint');
const ruleName = 'plugin/token-usage';
const messages = stylelint.utils.ruleMessages(ruleName, {
rejected: (token) => `Unregistered design token: "${token}"`,
});
const meta = { url: 'https://your-docs/plugin-token-usage' };
/** @type {import('stylelint').Rule} */
const rule = (primaryOption) => {
return (root, result) => {
const validOptions = stylelint.utils.validateOptions(result, ruleName, {
actual: primaryOption,
possible: Object,
});
if (!validOptions) return;
// Strict token pattern: var(--ds-[namespace]-[token-name])
const tokenPattern = /var\(--ds-([a-z0-9-]+)\)/gi;
const registry = new Set(primaryOption.registry || []);
root.walkDecls((decl) => {
let match;
// Reset lastIndex for global regex on each declaration
tokenPattern.lastIndex = 0;
while ((match = tokenPattern.exec(decl.value)) !== null) {
if (!registry.has(match[1])) {
stylelint.utils.report({
message: messages.rejected(match[0]),
node: decl,
result,
ruleName,
word: match[0],
});
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
module.exports = stylelint.createPlugin(ruleName, rule);
module.exports.ruleName = ruleName;
module.exports.messages = messages;
Why this works: stylelint.createPlugin wraps the rule function in the shape the runner expects, so the rule appears in the plugins array without Stylelint complaining about an unknown key. Using new Set() for the registry gives O(1) lookups even with thousands of token names.
Key implementation note: When using a global regex (/g flag) inside a loop, always reset lastIndex = 0 before each new string test. Failing to do so causes exec() to resume from the previous match position on the next string, silently skipping matches.
Step 2 — Load the token registry from your manifest:
Replace the hardcoded registry array with a runtime load from your token manifest. This ensures the rule stays in sync with the source of truth without manual maintenance.
// lib/rules/token-usage.js (registry loading variant)
const path = require('path');
const fs = require('fs');
function loadRegistry(manifestPath) {
const abs = path.resolve(process.cwd(), manifestPath || 'tokens.json');
const raw = JSON.parse(fs.readFileSync(abs, 'utf8'));
// Flatten nested token objects into a flat set of keys like "color-action-primary"
function flatten(obj, prefix = '') {
return Object.entries(obj).flatMap(([k, v]) => {
const key = prefix ? `${prefix}-${k}` : k;
return v && typeof v === 'object' && !v.$value ? flatten(v, key) : [key];
});
}
return new Set(flatten(raw));
}
Why this works: Flattening the nested token JSON into color-action-primary-style keys mirrors how --ds-color-action-primary is structured in CSS, so the regex capture group and the registry keys are directly comparable without transformation at match time.
Step 3 — Register the plugin in .stylelintrc.json:
{
"plugins": ["./lib/rules/token-usage.js"],
"rules": {
"plugin/token-usage": [true, {
"registry": ["color-surface", "color-text-primary", "space-4", "space-8"]
}]
}
}
Step 4 — Write a unit test for the rule:
Testing the rule in isolation prevents regressions when the regex or registry logic changes.
// lib/rules/token-usage.test.js
const { lint } = require('stylelint');
test('rejects unregistered token', async () => {
const result = await lint({
code: '.btn { color: var(--ds-color-unknown); }',
config: {
plugins: ['./lib/rules/token-usage.js'],
rules: {
'plugin/token-usage': [true, { registry: ['color-action-primary'] }],
},
},
});
expect(result.results[0].warnings).toHaveLength(1);
expect(result.results[0].warnings[0].text).toMatch('color-unknown');
});
test('passes registered token', async () => {
const result = await lint({
code: '.btn { color: var(--ds-color-action-primary); }',
config: {
plugins: ['./lib/rules/token-usage.js'],
rules: {
'plugin/token-usage': [true, { registry: ['color-action-primary'] }],
},
},
});
expect(result.results[0].warnings).toHaveLength(0);
});
CI Pipeline Integration and Execution
Integrate the custom rule into your CI workflow by registering it in the .stylelintrc.json plugins array and configuring the rule severity. Use stylelint --cache with a dedicated .stylelintcache directory to optimize execution times in monorepo environments, skipping unchanged files across incremental builds. Configure parallel execution across component packages using CI matrix strategies or turbo run lint to prevent pipeline bottlenecks. Set the rule to severity: "error" to fail the build on violations, while allowing severity: "warning" during gradual adoption phases.
# .github/workflows/lint.yml
name: CSS Lint
on: [push, pull_request]
jobs:
stylelint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run Stylelint
run: npx stylelint "**/*.css" --cache --cache-location .stylelintcache
Monitor CI logs for stylelint exit codes (2 for lint errors, 1 for fatal errors, 0 for clean) to automate PR gating.
Verification
After wiring the rule into CI, confirm it behaves as expected before enforcing it at severity: "error":
-
Run against a known-bad file locally:
echo ".foo { color: #ff0000; }" | npx stylelint --stdin-filename test.cssYou should see a
plugin/token-usagewarning for#ff0000if your rule also catches raw hex values via an additionalwalkDeclspass. -
Confirm clean files produce no output:
npx stylelint "src/**/*.css" --formatter compactZero lines of output means no violations.
-
Check exit codes in CI: A
plugin/token-usageviolation exits with code2. Confirm your CI step is set tocontinue-on-error: false(the default) so the job actually fails. -
Inspect the JSON report for triage:
npx stylelint "src/**/*.css" --formatter json > lint-report.json cat lint-report.json | jq '[.[] | select(.warnings | length > 0)]'This surfaces only files with violations, which is useful for tracking migration progress week over week.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Rule runs but reports zero violations despite invalid tokens | Incorrect PostCSS node traversal scope or missing walkDecls callback |
Verify AST structure with postcss.parse(), ensure traversal includes nested selectors and at-rules |
CI fails with MODULE_NOT_FOUND for custom plugin |
Plugin not published or incorrect relative path in config | Use path.resolve(__dirname, './lib/rules/token-usage.js') or publish to an internal npm registry |
| False positives on CSS variables or dynamic values | Overly permissive regex matching non-token var() references |
Implement strict prefix validation (^--ds-), add ignore patterns for runtime-injected values |
| Global regex skips every other match | lastIndex not reset between decl.value strings when using the g flag |
Reset tokenPattern.lastIndex = 0 before each exec() call on a new string |
| Rule absent from Stylelint output entirely | Plugin path resolved but module.exports shape is wrong |
Ensure module.exports = stylelint.createPlugin(ruleName, rule) is the default export |
Migration Note
Migrating legacy codebases requires a phased enforcement strategy to avoid blocking developer velocity.
Phase 1 — Audit without blocking: Set severity: "warning" and generate a baseline violation report:
npx stylelint "src/**/*.css" --formatter json > violations.json
Use automated scripts to batch-replace deprecated token aliases with canonical references, leveraging AST-aware codemods to preserve selector specificity. The same technique used by automated token audit scripts for orphan detection can be adapted to cross-reference your violation list against the token manifest.
Phase 2 — Escalate to error: Once coverage exceeds 90% and remaining violations are isolated to deprecated vendor components, switch to severity: "error".
Phase 3 — Enforce in PR gating: Add the lint step to your branch protection required checks. Maintain a centralized changelog mapping deprecated tokens to their replacements, and expose this mapping via a CLI utility to streamline developer onboarding.
Diagnostic Workflows and Root Cause Analysis
When custom rules fail silently or produce false positives, isolate the issue using stylelint --debug. This flag outputs verbose AST parsing logs, plugin resolution paths, and rule execution traces. Common root causes include malformed PostCSS AST nodes, incorrect regex boundaries that inadvertently match utility classes, or stale cached results from a previous run. Resolve AST mismatches by explicitly defining the parser via CLI flags or config (customSyntax: "postcss-scss" or "postcss-less"). Fix false positives by implementing strict token namespace validation and adding explicit ignore patterns for vendor-prefixed properties or dynamic runtime values injected by CSS-in-JS runtimes.
Related
- Stylelint Plugin Configuration — the parent reference covering plugin setup, rule severity configuration, and
.stylelintrc.jsonstructure. - Detecting Orphaned and Unused Tokens in CI — pair this rule with an audit script to catch tokens that exist in the registry but are never consumed.
- Validating Design Tokens Against JSON Schema in CI — validate the shape of your token manifest before the Stylelint rule loads it at runtime.