Enforcing Token-Only Colour Values with Stylelint

Part of Stylelint Plugin Configuration. Every design system eventually wants this rule, and most first attempts get disabled within a month. The difference is entirely in the exceptions, the message and the rollout.

What the rule should and should not flag Three categories of colour value: token references which pass, raw literals in themed properties which fail, and legitimate exceptions such as transparent, currentColor and gradients in third-party overrides. Passes var(--ds-color-surface) currentColor transparent inherit / unset no design decision made Fails color: #2563eb background: rgb(15 23 42) border-color: red box-shadow with a literal a value outside the system Exceptions third-party widget overrides print stylesheets the token definitions themselves forced-colors blocks documented, with a reason
The third column is what determines whether the rule survives. A rule with no exception path gets a blanket disable comment instead.

Prerequisites Link to this section

  • A published token manifest the rule can read, so the allow-list is generated rather than hand-maintained.
  • Stylelint 16 or later with a plugin entry point, as set up in the parent area.
  • A decision about which directories are converted, since the rule should be enabled per path rather than repository-wide.
  • Agreement that an exception requires a stated reason — this is a review convention, not a technical one, and it is the part that matters most.

Step-by-Step Implementation Link to this section

Step 1 — Decide which properties carry colour decisions Link to this section

Intent: check the properties where a literal is a design decision, and skip the ones where it is not.

const COLOR_PROPERTIES = new Set([
  'color', 'background', 'background-color', 'background-image',
  'border-color', 'border-top-color', 'border-right-color',
  'border-bottom-color', 'border-left-color', 'outline-color',
  'box-shadow', 'text-shadow', 'fill', 'stroke', 'caret-color',
  'text-decoration-color', 'column-rule-color', 'accent-color',
]);

Why this works: fill and stroke belong on the list because inline SVG in components is exactly where hard-coded brand colours accumulate, and they are invisible to a rule that only checks text and background. accent-color matters because it themes native form controls, which are frequently the last thing to get tokenised.

Step 2 — Detect a literal without rejecting legitimate keywords Link to this section

Intent: distinguish “a colour was chosen here” from “no colour decision was made”.

const ALLOWED_KEYWORDS = new Set([
  'transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert', 'none',
]);

const LITERAL = /#[0-9a-f]{3,8}\b|\b(rgb|rgba|hsl|hsla|oklch|lab|color)\(/i;

function findLiteral(value) {
  const cleaned = value.replace(/var\([^)]*\)/g, '');   // token references are fine
  if (ALLOWED_KEYWORDS.has(cleaned.trim().toLowerCase())) return null;
  const match = LITERAL.exec(cleaned);
  return match ? match[0] : null;
}

Why this works: stripping var() calls before matching is what allows a token reference with a literal fallback — var(--ds-color-surface, #ffffff) — to pass, which is a pattern components legitimately use. Matching after the strip means only literals outside a token reference are flagged.

Step 3 — Suggest the token in the message Link to this section

Intent: the message should name the fix, not just the problem.

function suggest(literal, manifest) {
  const normalised = normaliseColor(literal);
  const exact = Object.entries(manifest)
    .filter(([, t]) => t.$type === 'color' && normaliseColor(t.value) === normalised)
    .map(([name]) => name);
  if (exact.length) return `use ${exact[0]}`;

  const near = nearestByDistance(normalised, manifest, 3);   // small colour distance
  return near.length
    ? `nearest tokens: ${near.join(', ')} — or add a token if none of these is right`
    : 'no token matches this value; add one or document an exception';
}

Why this works: three outcomes, three different messages. An exact match means the fix is mechanical and should also be autofixable. A near match means a design conversation about whether the intended value already exists. No match at all means either a genuinely new colour or a mistake, and saying so plainly is more useful than a generic rejection.

Step 4 — Autofix the exact matches Link to this section

Intent: convert the mechanical majority automatically so human attention goes to the ambiguous cases.

module.exports = stylelint.createPlugin(ruleName, (options, secondary, context) =>
  (root, result) => {
    root.walkDecls((decl) => {
      if (!COLOR_PROPERTIES.has(decl.prop.toLowerCase())) return;
      const literal = findLiteral(decl.value);
      if (!literal) return;

      const exact = exactTokenFor(literal, manifest);
      if (exact && context.fix) {
        decl.value = decl.value.replace(literal, `var(${exact})`);
        return;
      }
      stylelint.utils.report({
        ruleName, result, node: decl,
        index: declarationValueIndex(decl) + decl.value.indexOf(literal),
        endIndex: declarationValueIndex(decl) + decl.value.indexOf(literal) + literal.length,
        message: messages.rejected(literal, suggest(literal, manifest)),
      });
    });
  });

Why this works: autofixing only exact matches is the safe boundary — replacing #2563eb with the token whose value is exactly #2563eb cannot change the rendered result. Autofixing a near match would change pixels, which a lint rule should never do silently. Reporting a precise index and endIndex underlines the literal itself rather than the whole declaration.

Three outcomes when a literal is found An exact token match leading to an autofix, a near match leading to a suggestion and a human decision, and no match leading to either a new token or a documented exception. Literal found exact token match autofix, no pixel change most of the backlog near match suggest, never autofix a design decision no match add a token, or document an exception Only the left branch is automatable, and it is usually 70 to 80 percent of the findings in a first run.
Separating the three outcomes is what makes the rule adoptable: the bulk is fixed mechanically, and human attention goes to the cases that need judgement.

Step 5 — Provide the exception path Link to this section

Intent: legitimate literals exist, and the rule must have a route for them that leaves a record.

/* stylelint-disable-next-line ds/token-only-colors --
   Third-party widget hard-codes this brand colour in its own stylesheet;
   we match it deliberately. Tracked in DS-482. */
.chat-widget__header { background: #4a154b; }

Enforce the reason with a companion configuration:

{
  "rules": {
    "ds/token-only-colors": true,
    "stylelint-disable-reason": "always"
  }
}

Why this works: requiring a reason turns a disable comment from an escape into a decision. A reviewer can evaluate “this matches a third-party widget” and cannot evaluate a bare disable, and a quarterly grep over the disable comments becomes a genuinely useful inventory of where the system does not reach.

Step 6 — Scope by path and severity during rollout Link to this section

Intent: enable the rule where the work has been done, and report elsewhere.

// .stylelintrc.mjs
export default {
  plugins: ['./stylelint-plugin-ds/index.js'],
  rules: { 'ds/token-only-colors': [true, { severity: 'warning' }] },
  overrides: [
    {
      files: ['packages/components/button/**', 'packages/components/card/**'],
      rules: { 'ds/token-only-colors': [true, { severity: 'error' }] },
    },
    {
      files: ['tokens/**', 'packages/styles/print.css'],
      rules: { 'ds/token-only-colors': null },     // token definitions are literals by nature
    },
  ],
};

Why this works: the token definition files are excluded entirely, because a token file is where literals belong — running the rule there produces failures on every line of the source of truth. Everything else warns, and converted directories error, so the boundary moves as conversion proceeds rather than blocking everyone from day one.

Verification Link to this section

Confirm the rule can fail, autofix correctly and be silenced deliberately. Add a literal that exactly matches a token and run with --fix, confirming the file now uses the token and the rendered output is unchanged. Add a literal with no matching token and confirm the message names the nearest candidates. Add a disable comment without a reason and confirm the configuration rejects it.

Then measure the two numbers that tell you whether the rollout is working: the count of remaining violations and the count of disable comments. A falling first number with a stable second is adoption; a falling first with a rising second is annotation, and it means either the autofix coverage is too low or the token set has a genuine gap.

Two rollout trajectories Two trend lines over six weeks: violations falling while disable comments stay flat, indicating genuine adoption, against violations falling while disable comments rise, indicating the rule is being annotated away. w1 w2 w3 w4 w5 w6 violations — falling in both cases disables flat — adoption disables rising — annotation Same violation curve, two very different outcomes
The violation count alone looks like success in both cases. Only tracking the disable count distinguishes a rule being adopted from one being routed around.

Troubleshooting Link to this section

Symptom Likely cause Fix
The rule fires on the token definitions Token files are not excluded Add an override setting the rule to null for tokens/**
var(--x, #fff) is flagged The literal fallback is being matched Strip var() calls before matching
Autofix changed a colour visibly A near match was autofixed Restrict autofix to exact value matches only
Disable comments proliferate No reason is required, or the token set has a gap Require reasons, then read them — they are a gap report
SVG fill literals are missed fill and stroke are not in the property list Add them; inline SVG is where brand colours hide

Migration Note Link to this section

The first run on an established codebase typically reports several hundred violations, of which most are exact matches. Run the autofix first, in its own commit, and check that the rendered output is byte-identical — the visual regression suite is the right tool for confirming that, and this is one of the cleanest cases for it.

What remains after the autofix is the interesting list: near matches and unmatched values. Sort it by frequency. A colour appearing thirty times is a missing token; one appearing once is probably a mistake or a genuine exception. Adding the handful of missing tokens usually clears most of the remainder, and the residue after that is small enough to review one by one.

What the Rule Cannot See Link to this section

A lint rule reasons about source text, and there are three places colour decisions hide from it entirely. Knowing them prevents the false confidence that comes from a green lint run.

Inline styles in templates and components are the largest gap. style={{ color: '#2563eb' }} in a component file is not CSS as far as Stylelint is concerned, and catching it needs a corresponding ESLint rule against the same manifest. Teams frequently ship the CSS rule, declare the problem solved, and leave the inline styles untouched.

Values computed at runtime are the second. A colour assembled from a variable — a chart palette built in JavaScript, a status colour selected by a lookup table — is invisible to static analysis by construction. The mitigation is to route those through the token system in code, reading resolved custom properties rather than holding literals, as covered in theming charts and canvas colours.

Third-party CSS is the third. A vendored stylesheet or a component library’s bundled styles contain whatever colours their authors chose, and linting them produces noise rather than action. Running the rule over the built bundle in reporting mode is the way to see them, and the response is usually an override rather than a fix.

None of these makes the CSS rule less worthwhile. They do mean that “the linter passes” is a claim about the stylesheets and not about the product, and stating that boundary honestly keeps the metric meaningful.