Enforcing Motion Tokens with Stylelint

Part of Motion Design Systems & Tokens in Core CSS Animation Fundamentals.

The problem

A team introduced motion tokens six months ago. A search today finds 140 uses of var(--motion-…) and 90 raw ms values in component styles, most of them added after the tokens existed. Code review catches some, but a transition: opacity .25s ease looks harmless in a diff, and nobody remembers which token is closest. The reduced-motion overrides in the token layer silently miss every one of those 90 transitions.

Tokens that are optional decay. A linter turns the convention into a check that runs on every commit.

Root cause analysis: what a motion lint has to catch

Motion values appear in more places than transition-duration, and each needs its own rule.

Timing properties. transition, transition-duration, transition-delay, transition-timing-function, animation, animation-duration, animation-delay and animation-timing-function can all carry raw values. The shorthands are the tricky ones: transition: opacity var(--motion-enter-duration) ease-out mixes a token duration with a raw easing keyword.

Easing functions. cubic-bezier(), linear() and steps() written directly in component styles bypass the easing scale. Keywords such as ease and ease-in-out do too.

transition: all. Independently of tokens, all animates layout and theme changes nobody intended, the regression described in why transition: all is a performance trap. A transition shorthand without a property name also means all.

Legitimate exceptions. 0s for disabling, none, inherit and CSS-wide keywords are fine. Token definition files must contain raw values by definition. Rare one-offs — a sprite animation with a frame-exact duration — need an explicit, commented disable.

Stylelint’s built-in rules cover most of this: declaration-property-value-disallowed-list can reject patterns in specific properties, and function-disallowed-list can reject functions. A strict-value plugin, such as stylelint-declaration-strict-value, goes further by requiring that listed properties use variables, functions or allowed keywords only.

Rules and what each catchesCombine built-in rules with a strict-value plugin for full coverage.Rules and what each catchesCatchesMissesdeclaration-property-value-disallowed-listtransition: all, raw ms in longhandsRaw values inside shorthands with tokensfunction-disallowed-listcubic-bezier(), linear(), steps()Keyword easings like ease-outStrict-value plugin onlonghandsAny non-var duration or easingShorthands unless configuredCode review aloneObvious casesMost small diffs
Combine built-in rules with a strict-value plugin for full coverage.

Step-by-step resolution

Rolling out motion lintingWarn, fix the hot spots, then fail the build.Rolling out motion linting1Add rules to a config override that applies only to component stylesheets.Token files are exempt2Reject transition: all and property-less transition shorthands.Removes the biggest regression source3Reject raw easing functions and keywords in timing properties.Curves must come from tokens4Require var() for duration and delay longhands via a strict-value rule.Durations must come from tokens5Run in warning mode and fix the files with the most violations first.Adoption without blocking work6Switch severity to error in CI once warnings are near zero.
Warn, fix the hot spots, then fail the build.

Production code pattern

// stylelint.config.js
export default {
  plugins: ['stylelint-declaration-strict-value'],
  overrides: [
    {
      files: ['src/components/**/*.css'],
      rules: {
        // 1. No transition: all (explicit or implied by a shorthand without a property).
        'declaration-property-value-disallowed-list': [
          {
            '/^transition(-property)?$/': ['/\\ball\\b/', '/^\\s*[\\d.]+m?s\\b/'],
            '/^(transition|animation)-timing-function$/': ['/^(ease|ease-in|ease-out|ease-in-out|linear)$/'],
          },
          { severity: 'warning', message: 'Use motion tokens and explicit transition properties' },
        ],
        // 2. No raw easing functions in components.
        'function-disallowed-list': [
          ['cubic-bezier', 'linear', 'steps'],
          { severity: 'warning', message: 'Use an easing token such as var(--motion-enter-easing)' },
        ],
        // 3. Duration and delay longhands must reference tokens.
        'scale-unlimited/declaration-strict-value': [
          ['transition-duration', 'transition-delay', 'animation-duration', 'animation-delay'],
          { ignoreValues: ['0s', '0ms', 'inherit', 'initial', 'unset'], severity: 'warning' },
        ],
      },
    },
  ],
};
/* Passes: tokens and explicit properties. */
.menu {
  transition:
    opacity var(--motion-exit-duration) var(--motion-exit-easing),
    translate var(--motion-exit-duration) var(--motion-exit-easing);
}

/* Documented exception: frame-exact sprite timing. */
.mascot__sheet {
  /* stylelint-disable-next-line function-disallowed-list -- sprite needs steps(12) */
  animation: wave 960ms steps(12, jump-end) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .mascot__sheet { animation: none; }
}

Rendering Impact: none. Linting runs at build time; the benefit shows up at runtime as consistent timing and reduced-motion coverage for every transition that uses tokens.

The regular expressions in rule 1 are deliberately conservative. The second pattern rejects a transition shorthand that starts with a duration, which is how “transition: .25s” — implicitly all — is usually written. Shorthands mixing tokens and raw keywords still slip through built-in rules; the strict-value plugin can be configured for shorthands too, at the cost of more false positives, so many teams lint longhands strictly and review shorthands.

Raw motion values in component styles during rolloutWarnings introduced in week 0; errors enabled in week 6.Raw motion values in component styles during rolloutWeek 090 violationsWeek 241 violationsWeek 412 violationsWeek 6 (errors on)0 violations
Warnings introduced in week 0; errors enabled in week 6.

Linting beyond CSS files

Motion values also live in CSS-in-JS, inline styles and element.animate() options. Stylelint can parse many CSS-in-JS syntaxes through custom syntaxes; for script animations, a small ESLint rule that flags numeric duration literals in animate() calls — or a shared helper that only accepts token names — covers the rest. Export the token values to JavaScript from the same source, as in sharing motion tokens across web and native, so script code has something to reference.

Verification checklist

Constraints and trade-offs

  • Regex-based rules can produce false positives on unusual but valid values; tune patterns against the real codebase.
  • Strict shorthand linting is noisy; many teams accept longhand-only strictness.
  • Lint rules cannot tell whether the chosen token is the right role; review still matters.
  • Third-party CSS and vendored components should be excluded, not rewritten.
  • Script animations need a separate lint or helper to be covered.

Frequently asked questions

Can Stylelint enforce CSS variables for animation durations?

Yes, with a strict-value plugin that requires listed properties to use variables or allowed keywords, or with disallowed-value patterns for raw times.

How do I ban transition: all with Stylelint?

Use declaration-property-value-disallowed-list with a pattern matching all in transition and transition-property, and a pattern for shorthands that start with a duration.

Should token files be linted with the same rules?

No. Token files necessarily contain raw values. Apply motion rules only to component and page stylesheets.

What about legitimate one-off values?

Allow them with a disable comment that states the reason, so exceptions are visible in review and searchable later.