I once led a team of 4 markup developers. First week, I opened a PR and found this:
// developer A
.card {
z-index: 999;
color: #fff;
padding: 13px;
display: flex;
align-items: center;
}
// developer B
.modal {
z-index: 9;
color: var(--color-white);
padding: 15px;
align-items: center;
display: flex;
}
Same project. Same component type. Completely different conventions. 13px vs 15px — nobody knows why. Raw hex vs custom property — nobody agreed. z-index: 9 vs z-index: 999 — nobody coordinated.
We had a kickoff meeting. We wrote down conventions. Two months later, everyone had forgotten half of them. A new team member joined — had no idea they existed.
That's when I stopped trying to get people to remember rules. I started making the rules impossible to forget.
Agreements die. Linters don't.
A convention written in a README is a wish. A Stylelint rule is a law.
The difference:
- Convention: "we don't use raw hex colors" → senior dev does it anyway under deadline → nobody says anything → standard erodes in two weeks
- Linter rule:
color: #fff→ build fails → nobody merges it → standard holds forever
A linter doesn't get tired. Doesn't make exceptions for itself. Doesn't forget after a vacation. It reviews every line of every PR, every time.
The goal isn't control — it's freedom. When your team doesn't need to remember conventions, they can think about the actual problem.
1. A token system for everything
The foundation: no magic numbers anywhere. Not in colors, not in spacing, not in typography. Everything through tokens.
Spacing tokens
Here's how it actually started.
A developer implemented a card. PR got reopened: "padding should be 12px, not 13px." Developer opens Figma — the mockup clearly shows 13px. Replies: "but the mockup says 13." Back and forth. Two people, both right, arguing about one pixel.
Why did it happen? The mockup had gone through 50 revisions before the task reached the developer. Client wanted the button bigger. PM wanted the card tighter. Designer moved things, adjusted, tweaked. After 50 iterations — nobody audited every spacing value. The padding that started as 16px in revision 3 became 13px by revision 47. Nobody noticed. The designer didn't notice. The developer didn't know it was wrong.
Nobody was at fault. There was no contract.
So we made one. We agreed with the design team: all spacing follows a 4px grid. Every value is a multiple of 4 — 4, 8, 12, 16, 24, 32, 48, 64. If the mockup shows 13px — it's 12. No discussion. No Slack thread. The contract decides.
But a verbal contract still depends on memory. A developer under deadline opens Figma, sees 13px, types padding: 13px — because that's what the mockup says. So we locked the contract in code and banned arbitrary pixel values entirely. Padding and margin can only come from the grid:
// _spacing.scss — only these values exist
$indent-xs: 4px;
$indent-sm: 8px;
$indent-md: 16px;
$indent-lg: 24px;
$indent-xl: 32px;
$indent-2xl: 48px;
$indent-3xl: 64px;
The rule: you can't write an arbitrary pixel value. Only tokens — or multiply/add them:
// ✅ allowed
.card {
padding: $indent-md; // 16px
gap: $indent-sm; // 8px
margin-bottom: $indent-md + $indent-sm; // 24px
padding-top: $indent-lg * 2; // 48px
}
// ❌ build error — where did 13px come from?
.card {
padding: 13px;
gap: 6px;
}
Enforced in Stylelint — padding and margin can only use SCSS variables, not raw pixel values:
"scale-unlimited/declaration-strict-value": [
[
"padding", "padding-top", "padding-right",
"padding-bottom", "padding-left",
"margin", "margin-top", "margin-right",
"margin-bottom", "margin-left",
"gap", "row-gap", "column-gap"
],
{
"ignoreValues": ["0", "auto"]
}
]
0 and auto are exceptions — margin: 0 auto for centering still works. Everything else must be a variable. The developer physically cannot type an arbitrary pixel value and merge it.
And if the team ever decides to switch from a 4px grid to a 3px grid — you change one variable. Every spacing value across 200 files recalculates automatically. Without tokens, that's a grep-and-pray operation with no guarantee you found everything.
Color tokens
// _colors.scss
$colors: (
primary: #6366f1,
primary-light: #a5b4fc,
text-primary: #1e1b4b,
text-secondary: #6b7280,
surface: #ffffff,
surface-dark: #0d0f16,
);
:root {
@each $name, $value in $colors {
--color-#{$name}: #{$value};
}
}
Enforced in Stylelint — writing raw hex in a component is a build error:
"scale-unlimited/declaration-strict-value": [
["/color$/", "fill", "stroke"],
{
"ignoreValues": ["transparent", "inherit", "currentColor", "none"]
}
]
Every color is named, documented, and changeable from one place.
z-index tokens
// _z-index.scss
$z-layers: (
base: 1,
dropdown: 100,
sticky: 200,
modal: 300,
tooltip: 400,
toast: 500,
);
@function z($layer) {
@return map-get($z-layers, $layer);
}
.modal { z-index: z(modal); } // 300
.tooltip { z-index: z(tooltip); } // 400
Enforced in Stylelint — writing a raw number is a build error:
"scale-unlimited/declaration-strict-value": [
["z-index"],
{ "ignoreValues": ["auto", "inherit"] }
]
Now z-index: 9999 fails. Only z-index: z(modal) passes. The function is the contract — you can't accidentally invent a new stacking value, you have to name it.
2. Typography as data
Instead of scattering font-size, font-weight, line-height across 50 files — every text style as a SCSS map, applied with one mixin:
// _typography.scss
$text-styles: (
h1: (
'font-family': var(--font-heading),
'font-size': $indent-3xl, // 64px — from spacing scale
'font-weight': 700,
'line-height': 110%,
'letter-spacing': -0.02em,
),
h2: (
'font-family': var(--font-heading),
'font-size': $indent-2xl, // 48px
'font-weight': 600,
'line-height': 115%,
),
h3: (
'font-family': var(--font-heading),
'font-size': $indent-xl, // 32px
'font-weight': 600,
'line-height': 120%,
),
body-lg: (
'font-family': var(--font-body),
'font-size': $indent-md, // 16px
'font-weight': 400,
'line-height': 160%,
),
body-sm: (
'font-family': var(--font-body),
'font-size': $indent-sm + $indent-xs, // 12px
'font-weight': 400,
'line-height': 150%,
),
caption: (
'font-family': var(--font-body),
'font-size': $indent-sm, // 8px
'font-weight': 400,
'line-height': 140%,
),
);
@mixin text-style($style) {
$styles: map-get($text-styles, $style);
@each $prop, $value in $styles {
#{$prop}: $value;
}
}
Usage:
.hero__title {
@include text-style(h1);
// one line. that's it.
}
.card__description {
@include text-style(body-lg);
color: var(--color-text-secondary); // only override what differs
}
Notice: font sizes come from the spacing scale. $indent-3xl is 64px. This isn't just DRY — it's intentional. When the design team says "h1 should be 4× the base unit", you write $indent-md * 4 and both systems stay in sync automatically. Spacing and typography speak the same language.
New developer opens the file — understands the entire typography system in 5 minutes.
3. Property order as architecture
Random property order isn't just ugly — it makes code harder to scan. When debugging a layout issue, you want to find position and z-index immediately, not hunt through 20 properties.
We enforced an order that mirrors how the browser processes styles:
.component {
// 1. Local SCSS variables
$local-spacing: $indent-md;
// 2. Extend
@extend %clearfix;
// 3. Mixins — before declarations, they generate declarations
@include text-style(body-lg);
// 4. Declarations — positioning → layout → box model → typography → visual
position: relative;
z-index: z(base);
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: $indent-md;
margin-bottom: $indent-lg;
color: var(--color-text-primary);
background: var(--color-surface);
border-radius: $indent-xs;
// 5. State pseudo-classes
&:hover { opacity: 0.9; }
&:focus { outline: 2px solid var(--color-primary); }
// 6. Pseudo-elements
&::before { content: ''; }
// 7. Media queries last
@include breakpoint(md) {
padding: $indent-lg;
}
}
Enforced via stylelint-order + stylelint-config-rational-order. The --fix flag auto-reorders on save. Nobody thinks about it — it just happens.
4. If you have a mixin, ban the property
This is the idea I haven't seen written anywhere else.
When you create a mixin that solves a problem — ban the raw CSS it replaces. Otherwise developers use both approaches in the same codebase and you get inconsistency anyway.
Example: flex layout
Instead of a simple flex-center mixin, we built a parametric flex mixin that covers all cases:
@mixin flex(
$type: default,
$direction: row,
$display: flex,
$align-items: null,
$justify-content: null
) {
@if $type == center {
align-items: center;
justify-content: center;
} @else if $type == between {
align-items: center;
justify-content: space-between;
}
display: $display;
flex-direction: $direction;
align-items: $align-items;
justify-content: $justify-content;
}
Usage:
.card { @include flex(center); } // centered
.header { @include flex(between); } // space-between
.sidebar { @include flex(default, column); } // column direction
Now ban display: flex written manually:
"declaration-property-value-disallowed-list": {
"display": ["flex", "inline-flex"]
}
display: flex in a component — build error. Only @include flex(...) passes. Every flex layout goes through one reviewed, consistent implementation.
Example: text truncation
@mixin truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
Every project has 10 places where text gets truncated. Without a mixin, each developer writes these three properties from memory — sometimes forgetting white-space: nowrap, sometimes writing text-overflow: clip instead of ellipsis. With a mixin, there's one implementation and it's always correct.
The mental model: a mixin is documentation of intent, not just a shortcut. When you ban the raw property, you force every developer to use the documented, reviewed, correct version. The linter enforces it — not memory, not code review, not a README.
5. Onboarding in one day
Here's what a new developer's first hour looked like with this system:
- Open
.stylelintrc— this is the architectural tour. Every rule tells you something about how the team thinks about CSS. - Write your first component. The linter tells you what's wrong immediately — in the editor, before the build runs.
- Try
padding: 13px— build error. You find the spacing tokens. You understand the system. - Try
color: #3b82f6— build error. You find the color tokens. You understand the design system. - Try
z-index: 999— build error. You find the z-index map. You understand the stacking architecture.
No README reading required. No pair programming session required. The system teaches itself through friction.
Good tooling reduces onboarding time more than good documentation. Documentation gets outdated. Linter rules are always current.
What we didn't automate — and why
Some things stayed as code review conventions. Not because we were lazy — but because automating them would create more friction than value.
Component naming — too project-specific. A generic regex can't tell the difference between a well-named and a poorly-named component in your domain. Humans are better at this.
When to use a mixin vs inline styles — requires context. Sometimes one display: flex is fine. Sometimes it should be @include flex-center. The linter can't know which — it depends on whether the pattern will repeat.
File organization — CSS Modules handle this naturally. The file lives next to the component. Done.
When to break the rules — sometimes padding: 3px is the right answer. A one-off visual tweak that doesn't belong in the token system. Good linters have stylelint-disable for a reason. The key: use it consciously, not habitually.
The rule of thumb: if you've caught the same mistake more than twice in code review — automate it. If catching it requires reading the surrounding code — keep it human.
6. Introducing this into an existing project
The hardest question: what do you do when you already have 200 files written the old way? You turn on Stylelint and get 847 errors. Nobody is going to fix 847 errors in one sprint.
The answer: introduce rules as warnings first, then escalate to errors over time.
// Phase 1 — warn, don't block
"scale-unlimited/declaration-strict-value": [
["/color$/"],
{ "severity": "warning" }
]
// Phase 2 (next sprint) — escalate to error
"scale-unlimited/declaration-strict-value": [
["/color$/"]
// no severity = defaults to error
]
The migration workflow we used:
Week 1: Add all rules as warning. CI passes. Developers see warnings in editor but aren't blocked. You get a baseline count of violations.
Week 2–4: Fix warnings file by file, starting with the most-touched components. Each PR you fix is a PR that will never regress.
Month 2: Flip rules to error one by one as violations reach zero. Now the standard holds permanently.
For files that are too risky to touch right now — use inline disable with a TODO:
/* stylelint-disable scale-unlimited/declaration-strict-value */
// TODO: migrate to tokens — PROJ-1234
.legacy-component {
color: #e8f0fe;
}
/* stylelint-enable scale-unlimited/declaration-strict-value */
The inline disable is visible, tracked, and searchable. It's not hiding the problem — it's acknowledging it with a ticket.
7. Two layers of enforcement: pre-commit + CI
Stylelint in a README is a wish. Stylelint in CI is a law. But CI feedback takes minutes — and slow feedback loops kill flow.
The solution: two layers. Pre-commit catches it locally in seconds. CI is the safety net if someone bypasses the hook.
Layer 1 — Pre-commit hook via Husky
npm install --save-dev husky lint-staged
npx husky init
// package.json
"lint-staged": {
"**/*.{css,scss}": "stylelint --fix"
}
# .husky/pre-commit
npx lint-staged
Now git commit runs Stylelint on staged files only — fast, targeted, auto-fixes what it can. If something can't be auto-fixed — commit is blocked with a clear error.
Can be bypassed with git commit --no-verify. That's why we need layer 2.
Layer 2 — GitHub Actions
# .github/workflows/lint.yml
name: Lint
on: [pull_request]
jobs:
stylelint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint:css
// package.json
"scripts": {
"lint:css": "stylelint '**/*.{css,scss}'"
}
Now every PR runs Stylelint in the cloud. Can't be bypassed. Can't be forgotten. The PR simply won't merge until the check passes.
The result: developers catch most issues locally via pre-commit before they ever push. The rare case where someone pushes with --no-verify — CI catches it before review. The reviewer never sees a linting issue — only logic.
npm install --save-dev \
stylelint \
stylelint-scss \
stylelint-order \
stylelint-config-rational-order \
stylelint-declaration-strict-value
Key rules:
{
"plugins": [
"stylelint-scss",
"stylelint-order",
"stylelint-config-rational-order/plugin",
"stylelint-declaration-strict-value"
],
"rules": {
"declaration-no-important": true,
"color-named": "never",
"scale-unlimited/declaration-strict-value": [
["/color$/", "fill", "stroke", "z-index",
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
"gap", "row-gap", "column-gap"],
{ "ignoreValues": ["transparent", "inherit", "currentColor", "none", "0", "auto"] }
],
"plugin/rational-order": [true, { "border-in-box-model": false }],
"order/order": [
"dollar-variables",
{ "type": "at-rule", "name": "extend" },
{ "type": "at-rule", "name": "include" },
"declarations",
{ "type": "rule", "selector": "^&:(hover|focus|active)" },
{ "type": "rule", "selector": "^&:(before|after)" },
{ "type": "at-rule", "name": "include", "parameter": "breakpoint" }
]
}
}
The result
After three months:
- PR reviews stopped being about conventions and started being about logic
- New developers wrote consistent code from day one
- Spacing changes took minutes instead of hours
- Nobody argued about
z-indexever again
Your job as a lead isn't to explain the same rules repeatedly. It's to build a system that explains itself.
The linter is that system.
This is the final article in my CSS series. It started with SCSS maps and runtime theming — the token system described here is the natural next step from that foundation.
What conventions have you automated in your team? Drop them in the comments.
Top comments (0)