A Stylelint plugin to enforce Defensive CSS best practices.
Getting Started | Quickstart | Plugin Configs | Plugin Rules | Troubleshooting
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.
After installation, add this to your .stylelintrc.json:
{
"plugins": ["stylelint-plugin-defensive-css"],
"extends": ["stylelint-plugin-defensive-css/configs/recommended"]
}For quick setup, the plugin provides preset configurations that enable commonly used rules.
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/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" }],
}
}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" }],
},
}The strict preset enables every rule for the most strict linting offered by the plugin.
Usage:
{
"extends": ["stylelint-plugin-defensive-css/configs/strict"]
}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
- 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
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,
}
}✅ Passing 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;
}
}
}
}❌ Failing Examples
.fail-btn:hover {
color: black;
}
@media (min-width: 1px) {
.fail-btn:hover {
color: black;
}
}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
}
}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"
}],
}
}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.
✅ Passing Examples
/* 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);
}❌ Failing Examples
/* 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;
}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 }]
}
}✅ Passing 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;
}❌ Failing Examples
ul {
list-style: none;
}
.menu ul {
list-style: none;
}
ol.items {
list-style: none;
}
:not(nav) ul {
list-style: none;
}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,
}
}✅ Passing Examples
input::-webkit-input-placeholder {
color: #222;
}
input::-moz-placeholder {
color: #222;
}❌ Failing Examples
input::-webkit-input-placeholder,
input::-moz-placeholder {
color: #222;
}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,
}
}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
}],
}
}✅ Passing 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;
}❌ Failing Examples
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');
}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,
}
}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"
}],
}
}✅ Passing Examples
div {
color: var(--color-primary, #000);
}❌ Failing Examples
div {
color: var(--color-primary);
}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,
}
}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"
}],
}
}✅ Passing 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;
}❌ Failing Examples
.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);
}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,
}
}✅ Passing 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;
}❌ Failing Examples
div {
display: flex;
}
div {
display: flex;
flex-direction: row;
}
div {
display: flex;
flex-flow: row;
}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,
}
}✅ Passing Examples
.btn:focus-visible {
outline: 2px solid blue;
}
.modal:focus-within {
border: 1px solid blue;
}
/* Intentional exclusion */
.input:not(:focus) {
border: 1px solid gray;
}❌ Failing Examples
.btn:focus {
outline: 2px solid blue;
}
button:focus {
outline: none;
}
.input:focus:hover {
border-color: blue;
}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,
}
}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" }]
}],
}
}✅ Passing 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);
}❌ Failing Examples
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;
}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,
}
}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" }]
}],
}
}✅ Passing 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;
}❌ Failing Examples
div {
overflow-x: auto;
}
div {
overflow: hidden scroll;
}
div {
overflow-block: auto;
}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
}
}✅ Passing 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;
}
}
}❌ Failing Examples
.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;
}
}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,
}
}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" }]
}],
}
}✅ Passing 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;
}❌ Failing Examples
div {
overflow-x: auto;
}
div {
overflow: hidden scroll;
}
div {
overflow-block: auto;
}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
}
}
]
}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);
}
