Modern CSS
Opinionated rules for modern CSS, so agents and people write it consistently.
These rules sit on top of the Modern Web Guidance skill, which must be installed. It explains what each feature does, when it applies, and how to fall back. It leaves choices open: which color space, whether to scope, when to nest, how to size type. These rules settle them; retrieve its guides for everything else. Where the two disagree, the rule wins.
The stylelint-config-modern package enforces every rule a linter can. This skill gets agents writing those from the start, and carries the rest.
Browser support policy: features within Baseline Newly Available are used natively, without @supports fallbacks, as progressive enhancement.
Rules
Architecture
Organizing styles (@layer)
- Rule: Put every style rule in a
@layer. - Constraint: Avoid unlayered element defaults and resets.
- Rationale: An unlayered rule outranks every layer; with everything layered, layer order alone decides conflicts.
- References:
@layeron MDN. - Example:
@layer elements, components;
@layer elements {
a {
text-decoration-skip-ink: auto;
}
}
Encapsulating styles (@scope)
- Rule: Use
@scopefor every component’s styles, with a limit (@scope (outer) to (inner)) where the component hosts content it doesn’t own: embedded components, slot content, or user content. - Constraint: Avoid component styles outside a scope, and an unlimited scope over content the component doesn’t own.
- Rationale: Scoped selectors can’t bleed into other components, and the limit stops the outer scope’s styles reaching projected content. Sometimes called donut scoping.
- References:
@scopeon MDN. - Example:
@scope (.card) to (.content) {
h2 {
font-size: var(--large);
}
}
Nesting rules and at-rules (&)
- Rule: Nest rules and at-rules inside their parent rule; write
&where the nested selector attaches to the parent (&:hover) and omit it for combinators (b, not& b). - Constraint: Avoid unnested selectors (e.g.
a {} a:hover {}). - Rationale: Keeps a rule’s states and queries beside it in one block, and stylelint-config-modern enforces the implicit
&form. - References:
&nesting selector on MDN. - Example:
a {
color: var(--blue);
&:hover {
text-decoration: underline;
}
code {
font-variant-numeric: tabular-nums;
}
}
Ranged queries (20em < width <= 40em)
- Rule: Use range syntax for size queries (e.g.
@container (20em < width <= 40em)), with ranges that don’t overlap. - Constraint: Avoid
min-width/max-widthand overlapping ranges (e.g.width <= 20emalongsidewidth >= 20em, which both match at20em). - Rationale: Each size lands in exactly one condition, so no condition has to override another.
- References: Media query range syntax on MDN.
- Example:
.card {
@container (width <= 20em) {
grid-template-columns: 1fr;
}
@container (20em < width <= 40em) {
grid-template-columns: 1fr 1fr;
}
@container (width > 40em) {
grid-template-columns: repeat(3, 1fr);
}
}
Layout
Flow-relative layout (*-inline-*, *-block-*, cqi/vi, start/end)
- Rule: Use flow-relative properties (e.g.
padding-block-start,inset-inline,inline-size), units (e.g.cqi,cqb,vi), and keywords (e.g.text-align: start) for layout. - Constraint: Avoid physical equivalents (e.g.
padding-top,width,cqw,vw,text-align: left), even where the layout would never flip. - Rationale: Keeps the box model on the axes flexbox and grid already use, and stylelint-config-modern enforces it.
- References: Logical properties and values on MDN.
- Example:
.card {
max-inline-size: 60ch;
padding-block-start: 2cqi;
text-align: start;
}
Two-value display (display: block flex)
- Rule: Use the two-value
displaysyntax (e.g.display: block flex,display: inline grid). - Constraint: Avoid the single-value keywords (e.g.
display: block,display: flex,display: inline-block). - Rationale: Separates how the box sits in its parent from how its children lay out, so
inline flexandinline flow-rootreplace the special-case keywordsinline-flexandinline-block. - References: Multi-keyword
displaysyntax on MDN. - Example:
nav {
display: block flex;
}
.card {
display: block grid;
}
Fluid spacing (cqi)
- Rule: Size spacing with container units (e.g.
padding: 2cqi), clamped where it needs a floor or ceiling. - Constraint: Avoid fixed spacing (e.g.
padding-block: 16px,margin-inline: 1rem). - Rationale: Spacing scales with the container, so components adapt wherever they’re placed, without the seams breakpoints leave.
- References: Container query length units on MDN, Responsive design: seams & edges..
- Example:
.card {
container: card / inline-size;
gap: 2cqi;
padding: clamp(1rem, 0.5rem + 2cqi, 2rem);
}
Auto-growing text areas (field-sizing: content)
- Rule: Use
field-sizing: contenton text areas, with an explicitinline-sizeandmin-block-size/max-block-sizeinlh. - Constraint: Avoid fixed block sizes (e.g.
block-size: 8rem) andfield-sizing: contenton inputs, which shrink to their value’s width. - Rationale: Typed text stays in view instead of scrolling inside a fixed box, and
lhbounds follow the line height as the type scales. - References:
field-sizingon MDN. - Example:
textarea {
field-sizing: content;
inline-size: 100%;
max-block-size: 12lh;
min-block-size: 3lh;
}
Typography
Fluid type sizes (clamp())
- Rule: Use
clamp()with container units for font sizes, on a type scale whose ratio grows with the container, e.g. Major Second (1.125) on narrow containers and Major Third (1.25) on wide ones. - Constraint: Avoid fixed font sizes (e.g.
px,rem) and central values without aremterm (e.g.clamp(1.75rem, 5cqi, 2.25rem)). - Rationale: Sizes text to its container rather than at breakpoints, and the
remterm keeps zoom working. - References:
clamp()on MDN, Designing with fluid type scales. - Example:
:root {
--medium: clamp(1.3125rem, 1.1821rem + 0.6522cqi, 1.6875rem);
--large: clamp(1.75rem, 1.5761rem + 0.8696cqi, 2.25rem);
}
Widow and orphan words (text-wrap)
- Rule: Use
text-wrap: balanceon headings andtext-wrap: prettyon all other text. - Constraint: Avoid default wrapping outside of inputs and text areas.
- Rationale: Removes widows and orphans by default rather than where someone remembered to opt in.
- References:
text-wrapon MDN. - Example:
h1,
h2,
h3 {
text-wrap: balance;
}
body {
text-wrap: pretty;
}
Colors
Perceptual uniform lightness (oklch())
- Rule: Use
oklch()for all colors. - Constraint: Avoid
hex,rgb(),hsl()and other color formats. - Rationale: Lightness is perceptually uniform, so text contrast holds across hues.
- References:
oklch()on MDN. - Example:
:root {
--success: oklch(40% 0.15 150deg);
--danger: oklch(40% 0.2 25deg);
}
Respecting color preferences (light-dark())
- Rule: Use
light-dark()for every color that differs between light and dark schemes. - Constraint: Avoid
prefers-color-schemeblocks that redeclare colors, and colors that don’t adapt. - Rationale: Each token carries both values in one place, so no scheme can be missed when a color changes.
- References:
light-dark()on MDN. - Example:
:root {
--surface: light-dark(oklch(98% 0 0deg), oklch(16% 0 0deg));
--text: light-dark(oklch(35% 0 0deg), oklch(75% 0 0deg));
}
Relative color functions (oklch(from /* .. */) & color-mix() )
- Rule: Derive related colors with relative color syntax (e.g.
oklch(from var(--primary) l c calc(h - 10deg))) orcolor-mix()inoklab. - Constraint: Avoid hardcoding colors that relate to other colors, and lightness-only moves on saturated colors (e.g.
oklch(from var(--primary) 90% c h)). - Rationale: Related colors follow their source when it changes.
- References: relative colors on MDN,
color-mix()on MDN. - Example:
:root {
--primary: oklch(55% 0.25 350deg);
--primary-hover: oklch(from var(--primary) l c calc(h - 10deg));
--primary-tint: color-mix(in oklab, var(--primary), oklch(100% 0 0deg) 80%);
}
Motion
Respecting motion preferences (prefers-reduced-motion)
- Rule: Use
prefers-reduced-motion: no-preferencewhen applying large animations and transitions. - Constraint: Avoid
prefers-reduced-motion: reduce. - Rationale: Motion becomes opt-in, so no animation needs a reduced-motion override. With
reduce, every animation needs one, and it’s easy to miss one. - References:
prefers-reduced-motionon MDN. - Example:
@media (prefers-reduced-motion: no-preference) {
.hero {
animation: bounce-in 0.5s ease;
}
}
Applying these rules
When asked to author, refactor, or review CSS:
- Identify which rules apply to the task; where several do, apply them all.
- Read surrounding files to see which rules are already in use; match their patterns.
- Apply the rules consistently across the change set, not just at the touch points.
- Retrieve the Modern Web Guidance guides for the task and apply them within these rules, skipping their fallbacks under the browser support policy above.
- If the project runs Stylelint with stylelint-config-modern, run it on the changed files and fix what it reports before finishing.
Gotchas
Traps neither the rules above nor Modern Web Guidance surface:
clamp()central values need aremterm:clamp(1.75rem, 1.5761rem + 0.8696cqi, 2.25rem), not theclamp(1rem, 5cqi, 2.5rem)form Modern Web Guidance’sfluid-scalingguide shows.grid-template-rows: subgridonly inherits tracks the child claims. Pair it withgrid-row: span N; without the span the child gets one row and aligns with nothing. Modern Web Guidance’scss-layoutguide shows the span without saying why.container-typealready applies containment: a component withcontainer-type: inline-sizehas layout, style and inline-size containment, so it needs nocontain. Thecontain: layout style paintthat Modern Web Guidance’sperformanceguide puts on widgets adds paint containment, which clips overflowing descendants likeoverflow: clip.
Examples
This site follows the rules:
src/styles.css:@layersrc/variables.css:oklch(),light-dark(),clamp()src/elements.css:&,text-wrap,prefers-reduced-motion,cqi, two-valuedisplay,field-sizingsrc/components/signpost/signpost.css:@scope
Fetch one only when the project has no CSS of its own to match.