stylelint-plugin-defensive-css
v2.5.0
Published
A Stylelint plugin to enforce defensive CSS best practices.
Maintainers
Readme

A Stylelint plugin to enforce Defensive CSS best practices.
Table of Contents
Getting Started | Quickstart | Plugin Configs | Plugin Rules | Troubleshooting
Getting Started
[!IMPORTANT] The plugin requires Stylelint v14.0.0 or greater.
To get started using the plugin, it must first be installed.
npm i stylelint-plugin-defensive-css --save-devyarn add stylelint-plugin-defensive-css --devWith the plugin installed, it must be added to the plugins array of your Stylelint config.
{
"plugins": ["stylelint-plugin-defensive-css"],
}After adding the plugin to the configuration file, you now have access to the various rules and options it provides.
Quickstart
After installation, add this to your .stylelintrc.json:
{
"plugins": ["stylelint-plugin-defensive-css"],
"extends": ["stylelint-plugin-defensive-css/configs/recommended"]
}Defensive CSS Configs
For quick setup, the plugin provides preset configurations that enable commonly used rules.
Recommended
The recommended preset enables core defensive CSS rules with sensible defaults, suitable for most projects.
Usage:
{
"extends": ["stylelint-plugin-defensive-css/configs/recommended"]
}Equivalent to:
{
"plugins": ["stylelint-plugin-defensive-css"],
"rules": {
"defensive-css/no-accidental-hover": [true, { "severity": "error" }],
"defensive-css/no-list-style-none": [true, { "fix": true, "severity": "error" }],
"defensive-css/no-mixed-vendor-prefixes": [true, { "severity": "error" }],
"defensive-css/no-unsafe-will-change": [true, { "severity": "error" }],
"defensive-css/require-background-repeat": [true, { "severity": "error" }],
"defensive-css/require-dynamic-viewport-height": [true, { "severity": "warning" }],
"defensive-css/require-flex-wrap": [true, { "severity": "error" }],
"defensive-css/require-focus-visible": [true, { "severity": "error" }],
"defensive-css/require-named-grid-lines": [
true,
{ "columns": [true, { "severity": "error" }] },
{ "rows": [true, { "severity": "warning" }] },
],
"defensive-css/require-prefers-reduced-motion": [true, { "severity": "error" }],
}
}Accessibility
The accessibility preset enables accessibility-focused rules to catch common issues that impact keyboard navigation, screen readers, and user preferences.
Usage:
{
"extends": ["stylelint-plugin-defensive-css/configs/accessibility"]
}Equivalent to:
{
"plugins": ["stylelint-plugin-defensive-css"],
"rules": {
"defensive-css/no-accidental-hover": [true, { "severity": "error" }],
"defensive-css/no-list-style-none": [true, { "fix": true, "severity": "error" }],
"defensive-css/require-focus-visible": [true, { "severity": "error" }],
"defensive-css/require-prefers-reduced-motion": [true, { "severity": "error" }],
},
}Strict
The strict preset enables every rule for the most strict linting offered by the plugin.
Usage:
{
"extends": ["stylelint-plugin-defensive-css/configs/strict"]
}Defensive CSS Rules
The plugin provides multiple rules that can be toggled on and off as needed.
- No Accidental Hover
- No Fixed Sizes
- No List Style None
- No Mixed Vendor Prefixes
- No Unsafe Will-Change
- Require Background Repeat
- Require Custom Property Fallback
- Require Dynamic Viewport Height
- Require Flex Wrap
- Require Focus Visible
- Require Named Grid Lines
- Require Overscroll Behavior
- Require Prefers Reduced Motion
- Require Scrollbar Gutter
No Accidental Hover
Hover effects indicate interactivity on devices with mouse or trackpad input. However, on touch devices, hover states can cause confusing user experiences where elements become stuck in a hovered state after being tapped, or trigger unintended actions.
Enable this rule to: Require all :hover selectors to be wrapped in @media (hover: hover) queries, ensuring hover effects only apply in supported contexts.
{
"rules": {
"defensive-css/no-accidental-hover": true,
}
}No Accidental Hover Examples
@media (hover: hover) {
.btn:hover {
color: black;
}
}
/* Will traverse nested media queries */
@media (hover: hover) {
@media (min-width: 1px) {
.btn:hover {
color: black;
}
}
}
/* Will traverse nested media queries */
@media (min-width: 1px) {
@media (hover: hover) {
@media (min-width: 100px) {
.btn:hover {
color: black;
}
}
}
}.fail-btn:hover {
color: black;
}
@media (min-width: 1px) {
.fail-btn:hover {
color: black;
}
}No Fixed Sizes
Fixed pixel (px) values prevent layouts from adapting to different screen sizes, user preferences, and device contexts. When widths, heights, spacing, and breakpoints are defined with px, content can overflow on small screens, create excessive whitespace on large displays, or ignore user font-size preferences set for accessibility.
Enable this rule to: Require relative or flexible units (rem, em, %, vw, fr, etc.) for sizing properties and media queries, ensuring layouts adapt gracefully across all contexts.
{
"rules": {
"defensive-css/no-fixed-sizes": true
}
}No Fixed Sizes Options
Configuration: By default, this rule validates critical sizing properties (width, height, font-size), spacing properties (margin, padding, gap), typography properties (line-height, letter-spacing), and responsive at-rules (@media, @container). Use the at-rules and properties options to customize which are checked or adjust their severity levels.
type Severity = 'error' | 'warning';
interface SecondaryOptions {
'at-rules'?: Partial<
Record<
CSSType.AtRules, boolean | [boolean, { severity?: Severity }]
>
>;
'properties'?: Partial<
Record<
keyof CSSType.PropertiesHyphen, boolean | [boolean, { severity?: Severity }]
>
>
"severity"?: Severity
}{
"rules": {
"defensive-css/no-fixed-sizes": [true, {
"at-rules": [{ "@container": false }],
"properties": [{ "transform": true, "scroll-margin": [true, { "severity": "warning" }] }],
"severity": "error"
}],
}
}No Fixed Sizes Examples
[!NOTE] This rule does not resolve or validate the values of CSS custom properties. Values like
var(--width)are treated as flexible since their actual values are not determined. Ensure your custom property definitions use relative units if they're used for sizing.
/* Sizing with relative units */
.box {
width: 50%;
height: 100vh;
font-size: 1.5rem;
}
/* Spacing with flexible units */
.card {
margin: 2rem auto;
padding: 1em 2em;
gap: 1rem;
}
/* Grid with fractional units */
.grid {
grid-template-columns: repeat(3, 1fr);
}
/* Functions with flexible units */
.responsive {
width: clamp(200px, 50%, 800px);
padding: calc(1rem + 2vw);
}
/* Media queries with relative units */
@media (min-width: 48rem) {
.box {
padding: 2rem;
}
}
/* Zero values are allowed */
.reset {
margin: 0;
padding: 0px;
}
/* Custom properties */
.themed {
width: var(--width);
margin: var(--spacing, 1rem);
}/* Fixed sizing */
.box {
width: 500px;
height: 300px;
font-size: 16px;
}
/* Fixed spacing */
.card {
margin: 20px;
padding: 10px 15px;
gap: 24px;
}
/* Grid with fixed values */
.grid {
grid-template-columns: 100px 1fr 100px;
}
/* Functions with only px */
.fixed {
width: clamp(200px, 400px, 800px);
padding: calc(10px + 5px);
}
/* Media queries with px */
@media (min-width: 768px) {
.box {
padding: 2rem;
}
}
/* Mixed units still fail if px is present */
.mixed {
margin: 1rem 20px;
line-height: 24px;
}No List Style None
[!TIP] This rule is fixable by passing the
{ fix: true }option.
In Safari, using list-style: none on <ul>, <ol>, or <li> elements removes list semantics from the accessibility tree, making the list invisible to VoiceOver users. Using list-style-type: "" (empty string) achieves the same visual result while preserving accessibility.
Exception: Lists inside <nav> elements maintain their semantics even with list-style: none, so this rule allows that pattern.
Enable this rule to: Prevent list-style: none on lists outside of navigation, requiring the accessible list-style-type: "" approach instead.
{
"rules": {
"defensive-css/no-list-style-none": [true, { "fix": true }]
}
}No List Style None Examples
/* Recommended: Preserves semantics */
ul {
list-style-type: "";
}
/* Exception: Lists inside nav elements retain semantics */
nav ul {
list-style: none;
}
/* Other list-style values are fine */
ul {
list-style: disc;
}ul {
list-style: none;
}
.menu ul {
list-style: none;
}
ol.items {
list-style: none;
}
:not(nav) ul {
list-style: none;
}No Mixed Vendor Prefixes
Grouping vendor-prefixed selectors in a single rule can cause the entire rule to be invalid according to the W3C selector specification. For example, combining -webkit- and -moz- placeholder selectors will prevent either from working correctly.
Enable this rule to: Require vendor-prefixed selectors to be separated into individual rules, ensuring browser-specific styles apply correctly.
{
"rules": {
"defensive-css/no-mixed-vendor-prefixes": true,
}
}No Mixed Vendor Prefixes Examples
input::-webkit-input-placeholder {
color: #222;
}
input::-moz-placeholder {
color: #222;
}input::-webkit-input-placeholder,
input::-moz-placeholder {
color: #222;
}Require Background Repeat
Background and mask images repeat by default when the container is larger than the image dimensions. On large screens, this can result in unintended tiling effects that break the design.
Enable this rule to: Require an explicit background-repeat or mask-repeat property whenever background-image or mask-image is used.
{
"rules": {
"defensive-css/require-background-repeat": true,
}
}Require Background Repeat Options
Configuration: By default, this rule validates both background and mask images. Use the background-repeat and mask-repeat options to control which properties are checked.
interface SecondaryOptions {
'background-repeat'?: boolean | [boolean, { severity?: Severity }];
'mask-repeat'?: boolean | [boolean, { severity?: Severity }];
}{
"rules": {
"defensive-css/require-background-repeat": [true, {
"background-repeat": [true, { "severity": "error" }],
"mask-repeat": false
}],
}
}Require Background Repeat Examples
div {
background: url('some-image.jpg') repeat black top center;
}
div {
background: url('some-image.jpg') black top center;
background-repeat: no-repeat;
}
div {
mask: url('some-image.jpg') repeat top center;
}
div {
mask: url('some-image.jpg') top center;
mask-repeat: no-repeat;
}div {
background: url('some-image.jpg') black top center;
}
div {
background-image: url('some-image.jpg');
}
div {
mask: url('some-image.jpg') top center;
}
div {
mask-image: url('some-image.jpg');
}No Unsafe Will-Change
[!WARNING] "
will-changeis intended to be used as a last resort, in order to try to deal with existing performance problems. It should not be used to anticipate performance problems." ~ MDN
The will-change CSS property hints to browsers about expected changes to an element, allowing them to optimize rendering ahead of time. However, misuse can cause serious performance issues: applying it to too many properties consumes excessive GPU memory, using it on non-composite properties provides no benefit, and applying it via the universal selector (*) forces GPU layers on every element, causing catastrophic performance degradation.
Enable this rule to: Prevent common will-change anti-patterns that harm performance rather than improve it.
{
"rules": {
"defensive-css/no-unsafe-will-change": true
}
}No Unsafe Will-Change Options
Configuration: By default, this rule allows up to 2 properties and errors on violations. Use the options below to customize validation.
type Severity = 'error' | 'warning';
interface SecondaryOptions {
ignore?: (keyof PropertiesHyphen)[];
maxProperties?: number;
severity?: Severity;
}{
"rules": {
"defensive-css/no-unsafe-will-change": [true, {
"maxProperties": 3,
"ignore": ["width"],
"severity": "error"
}],
}
}No Unsafe Will-Change Examples
/* Single composite property */
.card:hover {
will-change: transform;
}
/* Two composite properties (at default limit) */
.modal {
will-change: transform, opacity;
}
/* Composite property in pseudo-class */
.button:focus-visible {
will-change: opacity;
}
/* With ignore option for filter */
.element {
will-change: transform, opacity, filter;
/* Passes if maxProperties: 3 and ignore: ['filter'] */
}
/* Universal selector - forces GPU layers on all elements */
* {
will-change: transform;
}
/* Exceeds default maxProperties limit (3 > 2) */
.element {
will-change: transform, opacity, filter;
}
/* Non-composite properties (trigger layout/paint) */
.box {
will-change: width, height;
}
.positioned {
will-change: top, left;
}
.spaced {
will-change: margin, padding;
}
/* Mixed: exceeds limit AND contains non-composite property */
.card {
will-change: transform, opacity, width, height;
}
/* Universal selector in descendant */
.container > * {
will-change: opacity;
}Require Custom Property Fallback
CSS custom properties (variables) can fail silently if undefined, potentially breaking layouts or causing visual issues. Providing fallback values ensures graceful degradation when variables are missing or invalid.
Enable this rule to: Require all var() functions to include a fallback value (e.g., var(--color, #000)).
{
"rules": {
"defensive-css/require-custom-property-fallback": true,
}
}Require Custom Property Fallback Options
Configuration: By default, this rule validates all custom properties. Use the ignore option to exclude specific patterns, such as global design tokens or component-scoped variables.
interface SecondaryOptions {
ignore?: (string | RegExp)[];
}{
"rules": {
"defensive-css/require-custom-property-fallback": [true, {
"ignore": ["var\\(--exact-match\\)", /var\(--ds-color-.*\)/],
"severity": "warning"
}],
}
}Require Custom Property Fallback Examples
div {
color: var(--color-primary, #000);
}div {
color: var(--color-primary);
}Require Dynamic Viewport Height
On mobile browsers, the viewport height can change as the address bar and other UI elements collapse or expand during scrolling. Using static viewport units (100vh or 100vb) can cause content to be cut off or create unexpected layout shifts, particularly on iOS Safari and Chrome mobile.
Dynamic viewport units (100dvh, 100dvb) automatically adjust to the current viewport size, accounting for browser UI changes and providing a more reliable layout on mobile devices.
Enable this rule to: Flag usage of 100vh and 100vb on height-related properties and automatically fix them to use dynamic viewport units.
{
"rules": {
"defensive-css/require-dynamic-viewport-height": true,
}
}Require Dynamic Viewport Height Options
[!TIP] This rule is fixable by passing the
{ fix: true }option.
Configuration: By default, this rule validates height, block-size, max-height, and max-block-size properties. Use the properties option to customize which properties are checked and their severity level.
interface SecondaryOptions {
fix?: boolean;
properties?: {
'block-size'?: boolean | [boolean, SeverityProps];
height?: boolean | [boolean, SeverityProps];
'max-block-size'?: boolean | [boolean, SeverityProps];
'max-height'?: boolean | [boolean, SeverityProps];
'min-block-size'?: boolean | [boolean, SeverityProps];
'min-height'?: boolean | [boolean, SeverityProps];
};
}{
"rules": {
"defensive-css/require-dynamic-viewport-height": [true, {
"fix": true,
"properties": {
"height": [true, { "severity": "error" }],
"min-block-size": false,
},
"severity": "warning"
}],
}
}Require Dynamic Viewport Height Examples
.hero {
height: 100dvh;
}
.container {
block-size: 100dvb;
}
.modal {
max-height: 100dvh;
}
/* Small and large viewport units are also valid */
.element {
height: 100svh;
max-height: 100lvh;
}
/* Non-100 viewport units are allowed */
.partial {
height: 50vh;
max-height: 75vb;
}
/* min-height is not validated */
.flexible {
min-height: 100vh;
}
/* Width properties are not affected */
.wide {
width: 100vw;
}.hero {
height: 100vh;
}
.container {
block-size: 100vb;
}
.modal {
max-height: 100vh;
}
.overlay {
max-block-size: 100vb;
}
/* Also flags usage in functions */
.calculated {
height: calc(100vh - 20px);
}
.clamped {
block-size: clamp(100vb, 50vb, 100vb);
}Require Flex Wrap
Flex containers do not wrap their children by default. When there isn't enough horizontal space, flex items will overflow rather than wrapping to a new line, potentially breaking layouts on smaller screens.
Enable this rule to: Require an explicit flex-wrap property (or flex-flow shorthand) for all flex containers, ensuring predictable wrapping behavior is defined.
{
"rules": {
"defensive-css/require-flex-wrap": true,
}
}Require Flex Wrap Examples
div {
display: flex;
flex-wrap: wrap;
}
div {
display: flex;
flex-wrap: nowrap;
}
div {
display: flex;
flex-direction: row-reverse;
flex-wrap: wrap-reverse;
}
div {
display: flex;
flex-flow: row wrap;
}
div {
display: flex;
flex-flow: row-reverse nowrap;
}div {
display: flex;
}
div {
display: flex;
flex-direction: row;
}
div {
display: flex;
flex-flow: row;
}Require Focus Visible
The :focus pseudo-class shows focus indicators for both mouse clicks and keyboard navigation, which often leads developers to hide focus outlines entirely (creating accessibility issues). The :focus-visible pseudo-class only shows focus indicators when the user is navigating with a keyboard, providing a better user experience.
Enable this rule to: Require :focus-visible instead of :focus for better keyboard navigation UX.
{
"rules": {
"defensive-css/require-focus-visible": true,
}
}Require Focus Visible Examples
.btn:focus-visible {
outline: 2px solid blue;
}
.modal:focus-within {
border: 1px solid blue;
}
/* Intentional exclusion */
.input:not(:focus) {
border: 1px solid gray;
}.btn:focus {
outline: 2px solid blue;
}
button:focus {
outline: none;
}
.input:focus:hover {
border-color: blue;
}Require Named Grid Lines
Unnamed grid lines make layouts harder to understand and maintain. Numeric positions like grid-column: 1 / 3 are ambiguous and prone to errors when the grid structure changes. Named lines like [sidebar-start] provide clarity and self-documenting code.
Enable this rule to: Require all grid tracks to be associated with named lines using the [name] syntax in grid-template-columns, grid-template-rows, and the grid shorthand.
{
"rules": {
"defensive-css/require-named-grid-lines": true,
}
}Require Named Grid Lines Options
Configuration: By default, this rule validates both row and column lines. Use the columns and rows options to control which axes are checked.
interface SecondaryOptions {
columns?: boolean | [boolean, { severity?: Severity }];
rows?: boolean | [boolean, { severity?: Severity }];
}{
"rules": {
"defensive-css/require-named-grid-lines": [true, {
"columns": [true, { "severity": "error" }],
"rows": [true, { "severity": "warning" }]
}],
}
}Require Named Grid Lines Examples
div {
grid-template-columns: [c-a] 1fr [c-b] 1fr;
}
div {
grid-template-rows: [r-a] 1fr [r-b] 2fr;
}
div {
grid-template-columns: [a] [b] 1fr [c] 2fr;
}
div {
grid-template-columns: repeat(auto-fit, [line-a line-b] 300px);
}
div {
grid-template-rows: repeat(auto-fill, [r1 r2] 100px);
}
div {
grid: [r-a] 1fr / [c-a] 1fr [c-b] 2fr;
}
div {
grid-template-columns: repeat(auto-fit, [a]300px);
}div {
grid-template-columns: 1fr 1fr;
}
div {
grid-template-rows: 1fr 1fr;
}
div {
grid-template-columns: repeat(3, 1fr);
}
div {
grid-template-rows: repeat(3, 1fr);
}
div {
grid: auto / 1fr 1fr;
}
div {
grid: repeat(3, 1fr) / auto;
}
div {
grid-template-columns: 1fr [after] 1fr;
}
/* Reserved identifiers cannot be used as line names */
div {
grid-template-columns: [auto] 1fr;
}
div {
grid-template-rows: [span] 1fr;
}Require Overscroll Behavior
Scroll chaining occurs when a scrollable element reaches its scroll boundary and the scroll continues to the parent container. This commonly happens in modals where scrolling past the end causes the background content to scroll, creating a disorienting user experience.
Enable this rule to: Require an overscroll-behavior property for all scrollable containers (overflow: auto or overflow: scroll), preventing unintended scroll chaining.
{
"rules": {
"defensive-css/require-overscroll-behavior": true,
}
}Require Overscroll Behavior Options
Configuration: By default, this rule validates both horizontal and vertical overflow. Use the x and y options to control which axes are checked.
interface SecondaryOptions {
x?: boolean | [boolean, { severity?: Severity }];
y?: boolean | [boolean, { severity?: Severity }];
}{
"rules": {
"defensive-css/require-overscroll-behavior": [true, {
"x": [true, { "severity": "warning" }],
"y": [true, { "severity": "error" }]
}],
}
}Require Overscroll Behavior Examples
div {
overflow-x: auto;
overscroll-behavior-x: contain;
}
div {
overflow: hidden scroll;
overscroll-behavior: contain;
}
div {
overflow: hidden; /* No overscroll-behavior is needed in the case of hidden */
}
div {
overflow-block: auto;
overscroll-behavior: none;
}div {
overflow-x: auto;
}
div {
overflow: hidden scroll;
}
div {
overflow-block: auto;
}Require Prefers Reduced Motion
Some users experience motion sickness or vestibular disorders that make animations uncomfortable or even nauseating. The prefers-reduced-motion media query allows users to request minimal animation. Respecting this preference is crucial for accessibility.
Enable this rule to: Require all animations and transitions to be wrapped in a @media (prefers-reduced-motion: no-preference) or @media not (prefers-reduced-motion: reduce) query.
{
"rules": {
"defensive-css/require-prefers-reduced-motion": true
}
}Require Prefers Reduced Motion Examples
@media (prefers-reduced-motion: no-preference) {
.box {
transition: transform 0.3s;
}
}
@media (prefers-reduced-motion: no-preference) {
.box {
animation: slide 1s ease;
}
}
/* Instant transitions are allowed */
.box {
transition: transform 0s;
}
/* No animation is allowed */
.box {
animation: none;
}
@media not (prefers-reduced-motion: reduce) {
.box {
transition: transform 0s;
}
}
/* Nested media queries */
@media (prefers-reduced-motion: no-preference) {
@media (min-width: 768px) {
.box {
transition: transform 0.3s;
}
}
}
.box {
transition: transform 0.3s;
}
.box {
animation: slide 1s ease;
}
.box {
animation-duration: 0.5s;
}
/* Media query without prefers-reduced-motion */
@media (min-width: 768px) {
.box {
transition: transform 0.3s;
}
}Require Scrollbar Gutter
When content grows and triggers a scrollbar, the sudden appearance of the scrollbar causes a layout shift as content reflows to accommodate it. This creates a jarring visual jump, especially in dynamic interfaces where content changes frequently.
Enable this rule to: Require a scrollbar-gutter property for all scrollable containers, reserving space for the scrollbar and preventing layout shifts.
{
"rules": {
"defensive-css/require-scrollbar-gutter": true,
}
}Require Scrollbar Gutter Options
Configuration: By default, this rule validates both horizontal and vertical overflow. Use the x and y options to control which axes are checked.
interface SecondaryOptions {
x?: boolean | [boolean, { severity?: Severity }];
y?: boolean | [boolean, { severity?: Severity }];
}{
"rules": {
"defensive-css/require-scrollbar-gutter": [true, {
"x": [true, { "severity": "warning" }],
"y": [true, { "severity": "error" }]
}],
}
}Require Scrollbar Gutter Examples
div {
overflow-x: auto;
scrollbar-gutter: auto;
}
div {
overflow: hidden scroll;
scrollbar-gutter: stable;
}
div {
overflow: hidden; /* No scrollbar-gutter is needed in the case of hidden */
}
div {
overflow-block: auto;
scrollbar-gutter: stable both-edges;
}div {
overflow-x: auto;
}
div {
overflow: hidden scroll;
}
div {
overflow-block: auto;
}Troubleshooting
Third-Party False Positives
If you're getting warnings for properties you don't control (e.g., from third-party libraries), you can disable the rule for specific files in your Stylelint config file using the overrides property.
{
"overrides": [
{
"files": ["vendor/**/*.css"],
"rules": {
"defensive-css/no-mixed-vendor-prefixes": null
}
}
]
}Ignoring Specific Patterns
As an escape hatch, use Stylelint's built-in disable comments to bypass specific rules:
div {
/* stylelint-disable-next-line defensive-css/require-background-repeat */
background: url(./some-image.jpg);
}