StyleLint + Visual Regression Testing for CSS in CI
StyleLint enforces CSS code quality standards; visual regression tests catch the layout and styling bugs that StyleLint can't — the ones that only show up when you look at a browser. Combined in CI, they form a complete CSS quality gate.
Why CSS Needs Two Types of Testing
StyleLint checks your CSS code structure: naming conventions, property order, unit consistency, selector specificity. It finds code quality issues before they reach the browser.
Visual regression testing checks what the browser actually renders. A CSS rule can be syntactically valid and pass all linting rules but still break a component's layout — a wrong margin, an off-by-one in padding, a z-index that creates an overlap.
You need both.
StyleLint Setup
Installation
npm install --save-dev stylelint stylelint-config-standard stylelint-orderFor SCSS:
npm install --save-dev stylelint stylelint-config-standard-scssConfiguration
// .stylelintrc.json
{
"extends": [
"stylelint-config-standard",
"stylelint-config-standard-scss"
],
"plugins": [
"stylelint-order"
],
"rules": {
// Property order enforcement
"order/properties-alphabetical-order": true,
// Naming conventions
"selector-class-pattern": "^[a-z][a-z0-9-]*(__[a-z0-9-]+)?(--[a-z0-9-]+)?$",
// BEM pattern: block, block__element, block--modifier
// Color format consistency
"color-hex-length": "long",
"color-named": "never",
// Unit enforcement
"unit-allowed-list": ["px", "em", "rem", "%", "vh", "vw", "deg", "ms", "s"],
// No magic numbers — use CSS custom properties
"declaration-no-important": true,
// Specificity limits
"selector-max-id": 0, // No ID selectors
"selector-max-universal": 1, // Limit * selector usage
"selector-max-compound-selectors": 3,
// SCSS-specific
"scss/at-rule-no-unknown": true,
"scss/selector-no-redundant-nesting-selector": true
}
}Running StyleLint
// package.json
{
"scripts": {
"lint:css": "stylelint 'src/**/*.{css,scss}'",
"lint:css:fix": "stylelint 'src/**/*.{css,scss}' --fix",
"lint:css:ci": "stylelint 'src/**/*.{css,scss}' --formatter=github"
}
}--formatter=github outputs GitHub Actions annotations format — ESLint errors appear as inline comments on the PR diff.
Common StyleLint Rules and Why They Matter
Selector Specificity Rules
/* Bad: ID selector — high specificity, hard to override */
#header .nav-link { color: blue; }
/* Good: class selector */
.header__nav-link { color: blue; }"selector-max-id": 0 // Disallow ID selectors entirelyProperty Order
Consistent property order makes CSS scannable and prevents omitting properties. The common ordering: positioning → box model → typography → visual → miscellaneous.
"order/properties-order": [
"position", "top", "right", "bottom", "left", "z-index",
"display", "width", "height", "margin", "padding",
"font-size", "font-weight", "line-height", "color",
"background", "border", "border-radius",
"opacity", "transition", "animation"
]No !important
"declaration-no-important": true!important is a specificity hack. It indicates a broken cascade, not a solution.
Visual Regression Testing
Option 1: Playwright Screenshots + pixelmatch
Self-hosted, free, and flexible:
npm install --save-dev @playwright/test pixelmatch pngjs// tests/visual/homepage.visual.ts
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
const SNAPSHOTS_DIR = path.join(__dirname, 'snapshots');
const THRESHOLD = 0.1; // 10% pixel difference allowed
async function compareScreenshot(
page: any,
name: string
): Promise<{ passed: boolean; diffPixels: number }> {
const currentPath = path.join(SNAPSHOTS_DIR, `${name}-current.png`);
const baselinePath = path.join(SNAPSHOTS_DIR, `${name}-baseline.png`);
const diffPath = path.join(SNAPSHOTS_DIR, `${name}-diff.png`);
await page.screenshot({ path: currentPath, fullPage: true });
// First run: create baseline
if (!fs.existsSync(baselinePath)) {
fs.copyFileSync(currentPath, baselinePath);
return { passed: true, diffPixels: 0 };
}
// Compare
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const current = PNG.sync.read(fs.readFileSync(currentPath));
const diff = new PNG({ width: baseline.width, height: baseline.height });
const diffPixels = pixelmatch(
baseline.data, current.data, diff.data,
baseline.width, baseline.height,
{ threshold: THRESHOLD }
);
const totalPixels = baseline.width * baseline.height;
const diffPercentage = diffPixels / totalPixels;
if (diffPixels > 0) {
fs.writeFileSync(diffPath, PNG.sync.write(diff));
}
return {
passed: diffPercentage < 0.01, // Less than 1% of pixels changed
diffPixels
};
}
test('homepage visual regression', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForLoadState('networkidle');
const { passed, diffPixels } = await compareScreenshot(page, 'homepage');
expect(passed, `Homepage has ${diffPixels} changed pixels`).toBeTruthy();
});
test('navigation component visual regression', async ({ page }) => {
await page.goto('http://localhost:3000');
// Isolate the component
const nav = page.locator('nav.main-navigation');
await nav.screenshot({ path: '/tmp/nav-current.png' });
// Use Playwright's built-in snapshot comparison
await expect(nav).toHaveScreenshot('navigation.png', {
maxDiffPixelRatio: 0.01
});
});Option 2: Percy (Hosted)
Percy provides hosted visual diffing with a UI for reviewing changes:
npm install --save-dev @percy/cli @percy/playwright// tests/visual/percy-visual.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test('homepage', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForLoadState('networkidle');
await percySnapshot(page, 'Homepage');
});
test('mobile navigation', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('http://localhost:3000');
await page.click('[data-testid="mobile-menu-trigger"]');
await percySnapshot(page, 'Mobile Navigation - Open');
});# In CI: wrap with percy exec
- name: Run visual tests
run: npx percy exec -- npx playwright test tests/visual/
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}Percy shows visual diffs in a web UI, requiring human approval before the CI check passes.
Combining StyleLint + Visual Regression in CI
name: CSS Quality Gates
on:
push:
branches: [main]
pull_request:
jobs:
stylelint:
name: StyleLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run StyleLint
run: npm run lint:css:ci
# --formatter=github produces inline PR annotations
visual-regression:
name: Visual Regression Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build application
run: npm run build
- name: Start application
run: npm run start &
# Run server in background
- name: Wait for application
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Download visual baselines
uses: actions/cache@v4
with:
path: tests/visual/snapshots/
key: visual-baselines-${{ github.base_ref || 'main' }}
restore-keys: visual-baselines-
- name: Run visual regression tests
run: npx playwright test tests/visual/
- name: Upload diff images on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: tests/visual/snapshots/*-diff.png
- name: Update baselines cache
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: tests/visual/snapshots/
key: visual-baselines-main-${{ github.sha }}Updating Baselines
When a visual change is intentional (a new design or planned update), baselines need updating:
# Update all baselines locally
npx playwright <span class="hljs-built_in">test tests/visual/ --update-snapshots
<span class="hljs-comment"># Commit updated baselines
git add tests/visual/snapshots/
git commit -m <span class="hljs-string">"chore: update visual regression baselines for new nav design"Or trigger a baseline update from CI via a special label on the PR:
- name: Update visual baselines
if: contains(github.event.pull_request.labels.*.name, 'update-snapshots')
run: npx playwright test tests/visual/ --update-snapshots
- name: Commit updated baselines
if: contains(github.event.pull_request.labels.*.name, 'update-snapshots')
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update visual regression baselines [skip ci]"Summary
CSS quality testing has two complementary layers:
- StyleLint — enforces code standards: naming conventions, specificity limits, property order, forbidden patterns. Fast, runs in seconds, catches structural issues before they reach the browser.
- Visual regression — catches what linting can't: layout breaks, spacing regressions, responsive behavior. Slower but catches the bugs users actually see.
In CI:
- StyleLint runs first (fast, blocks immediately on violations)
- Visual tests run after build (slower, requires a running application)
- Baselines are cached and updated on intentional changes
The combination blocks CSS regressions at both the code level (StyleLint) and the rendered output level (visual diffing) without requiring manual review of every CSS change.