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.

Custom Stylelint rule catching a raw value vs a token Sequence diagram showing a CSS declaration with a raw hex color being rejected by the custom rule, while a declaration using a registered token passes. Source CSS color: #3b82f6; raw value color: var(--ds-color- action-primary); plugin/token-usage walkDecls() registry.has(token)? PostCSS AST traversal Token Registry tokens.json manifest VIOLATION utils.report() PASS no report emitted Raw value Token reference
A custom 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, and AtRule.
  • A .stylelintrc.json or stylelint.config.js already wired into your CI pipeline.
  • Optional: postcss-scss or postcss-less if 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":

  1. Run against a known-bad file locally:

    echo ".foo { color: #ff0000; }" | npx stylelint --stdin-filename test.css

    You should see a plugin/token-usage warning for #ff0000 if your rule also catches raw hex values via an additional walkDecls pass.

  2. Confirm clean files produce no output:

    npx stylelint "src/**/*.css" --formatter compact

    Zero lines of output means no violations.

  3. Check exit codes in CI: A plugin/token-usage violation exits with code 2. Confirm your CI step is set to continue-on-error: false (the default) so the job actually fails.

  4. 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.