CSS drift is real.
Even on teams with a spacing scale and token system, random values still creep in:
-
13pxmargins -
18pxtranslate animations - raw literals where tokens should be used
That drift chips away at consistency and makes UI behavior harder to reason about over time.
I built Rhythmguard to stop that at lint time.
What Rhythmguard enforces
stylelint-plugin-rhythmguard focuses on three rules:
rhythmguard/use-scale
Checks spacing values against your approved scale and can autofix to the nearest allowed value.rhythmguard/prefer-token
Enforces token usage over raw spacing literals and can autofix with an explicittokenMap.rhythmguard/no-offscale-transform
Applies scale rules to translation motion values (translateY, etc.) so motion rhythm stays aligned with layout rhythm.
Install and quick start
npm install --save-dev stylelint stylelint-plugin-rhythmguard
{
"plugins": ["stylelint-plugin-rhythmguard"],
"extends": ["stylelint-plugin-rhythmguard/configs/strict"]
}
You also get recommended and tailwind shared configs depending on how aggressively you want to enforce standards.
Why a plugin instead of guidelines
Docs and Figma tokens are necessary, but they’re not enforcement.
What teams actually need is:
- fast feedback in CI and local linting
- deterministic autofix for migration work
- guardrails that encode system rules, not preferences
That’s the gap Rhythmguard fills.
How the rule logic works
At a high level, each rule does this:
- Parse declaration values with
postcss-value-parser. - Target spacing-sensitive properties (
margin,padding,gap,inset, scroll spacing, transform translate functions, etc.). - Normalize units (
rem,em,px) to a comparable numeric scale. - Report violations via Stylelint’s reporting API.
- Apply a safe fix only when deterministic.
A simplified pattern looks like this:
const stylelint = require("stylelint");
const valueParser = require("postcss-value-parser");
const ruleName = "rhythmguard/use-scale";
module.exports = stylelint.createPlugin(ruleName, (enabled, opts = {}) => {
return (root, result) => {
if (!enabled) return;
root.walkDecls((decl) => {
if (!isSpacingProperty(decl.prop)) return;
const ast = valueParser(decl.value);
walkLengthNodes(ast, (node) => {
const px = toPx(node.value, opts.baseFontSize ?? 16);
if (onScale(px, opts.scale)) return;
stylelint.utils.report({
ruleName,
result,
node: decl,
message: `Off-scale value "${node.value}"`,
fix: opts.fixToScale ? () => (node.value = nearestScaleValue(px, opts.scale)) : undefined
});
});
decl.value = ast.toString();
});
};
});
The key is the fix strategy: only deterministic fixes. No guessing.
Real example: before and after
In the demo repo, this input is intentionally broken:
.card {
margin: 13px;
padding: 18px 22px;
gap: 12px;
transform: translateY(18px) scale(1);
}
With scale + token rules and an explicit token map, autofix converges it to consistent, tokenized output.
That gives you two wins at once:
- spacing values become scale-safe
- literal values migrate toward design tokens
Tailwind note (important)
Rhythmguard enforces what Stylelint can parse: CSS declarations.
It does not lint Tailwind class strings like class="p-[13px] translate-y-[18px]".
For full Tailwind governance, pair Rhythmguard with Tailwind-aware ESLint/class tooling.
Rollout strategy that works
If you’re introducing this to an existing codebase, do it in phases:
- Enable
rhythmguard/use-scalewith autofix. - Add
rhythmguard/no-offscale-transform. - Introduce
rhythmguard/prefer-tokenin migration mode. - Lock into strict token enforcement once your token map is complete.
This keeps the migration practical and avoids noisy, non-actionable linting.
Closing
Spacing consistency is one of those things teams care about until deadlines hit.
Rhythmguard turns that concern into enforceable, testable rules so standards survive real delivery pressure.
Sources:
Top comments (0)