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.
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.
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.
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.
Related Link to this section
- Stylelint Plugin Configuration — the parent area, including adoption strategy for any token rule
- Writing Custom Stylelint Rules for Token Usage — the rule anatomy this builds on
- Detecting Orphaned and Unused Tokens in CI — the complementary check, from the token side rather than the usage side