
Modern Css
- 158 installs
- 57 repo stars
- Updated July 7, 2026
- ccheney/robust-skills
Ship responsive, accessible interfaces using modern CSS layout, typography, and interaction patterns without relying on outdated hacks or excessive JavaScript.
About
Teaches robust modern CSS practices for building maintainable, responsive web interfaces using current layout, typography, theming, and interaction standards so teams can deliver polished UI with native browser features instead of legacy workarounds.
- Container queries and modern layout systems
- CSS Grid and Flexbox production patterns
- Custom properties and theming
- Accessible focus and motion practices
- Progressive enhancement without JS bloat
Modern Css by the numbers
- 158 all-time installs (skills.sh)
- Ranked #943 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ccheney/robust-skills --skill modern-cssAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 158 |
|---|---|
| repo stars | ★ 57 |
| Last updated | July 7, 2026 |
| Repository | ccheney/robust-skills ↗ |
What it does
Ship responsive, accessible interfaces using modern CSS layout, typography, and interaction patterns without relying on outdated hacks or excessive JavaScript.
Files
Modern CSS
Pure native CSS for building interfaces — no preprocessors, no frameworks.
When to Use (and When NOT to)
| Use Freely (Baseline) | Feature-Detect First |
|---|---|
| CSS Grid, Subgrid, Flexbox | @function, if() (Chrome-only) |
| Container Queries (size + style) | Customizable <select> (Chrome-only) |
:has(), :is(), :where() | Scroll-state queries (Chrome-only) |
CSS Nesting, @layer, @scope | sibling-index(), sibling-count() |
@property (typed custom props) | ::scroll-button(), ::scroll-marker |
oklch(), color-mix(), light-dark() | Typed attr() beyond content |
| Relative color syntax | field-sizing: content |
@starting-style, transition-behavior | interpolate-size (Chrome-only) |
| Scroll-driven animations | Grid Lanes / masonry (experimental) |
| Anchor positioning, Popover API | random() (Safari TP only) |
text-wrap: balance, linear() easing | @mixin / @apply (no browser yet) |
| View Transitions, logical properties |
CRITICAL: The Modern Cascade
Understanding how styles resolve is the single most important concept in CSS. The additions of @layer and @scope fundamentally changed the cascade algorithm.
Style Resolution Order (highest priority wins):
┌─────────────────────────────────────────────────┐
│ 1. Transitions (active transition wins) │
│ 2. !important (user-agent > user > author) │
│ 3. @layer order (later layer > earlier layer) │
│ 4. Unlayered styles (beat ALL layers) │
│ 5. Specificity (ID > class > element) │
│ 6. @scope proximity (closer root wins) NEW │
│ 7. Source order (later > earlier) │
└─────────────────────────────────────────────────┘
Unlayered > Last layer > ... > First layer
(utilities) (reset)Cascade layers (@layer) and scope proximity (@scope) are now more powerful than selector specificity. Define your layer order once (@layer reset, base, components, utilities;) and specificity wars disappear. Unlayered styles always beat layered styles — use this for overrides.
Quick Decision Trees
"How do I lay this out?"
Layout approach?
├─ 2D grid (rows + columns) → CSS Grid
│ ├─ Children must align across → Grid + Subgrid
│ └─ Waterfall / masonry → grid-lanes (experimental)
├─ 1D row OR column → Flexbox
├─ Component adapts to container → Container Query + Grid/Flex
├─ Viewport-based responsiveness → @media range syntax
└─ Element sized to content → fit-content / min-content / stretch"How do I style this state?"
Style based on what?
├─ Child/descendant presence → :has()
├─ Container size → @container (inline-size)
├─ Container custom property → @container style()
├─ Scroll position (stuck/snapped) → scroll-state() query
├─ Element's own custom property → if(style(...))
├─ Browser feature support → @supports
├─ User preference (motion/color) → @media (prefers-*)
└─ Multiple selectors efficiently → :is() / :where()"How do I animate this?"
Animation type?
├─ Enter/appear on DOM → @starting-style + transition
├─ Exit/disappear (display:none) → transition-behavior: allow-discrete
├─ Animate to/from auto height → interpolate-size: allow-keywords
├─ Scroll-linked (parallax/reveal) → animation-timeline: scroll()/view()
├─ Page/view navigation → View Transitions API
├─ Custom easing (bounce/spring) → linear() function
└─ Always: respect user preference → @media (prefers-reduced-motion)What CSS Replaced JavaScript For
| JavaScript Pattern | CSS Replacement |
|---|---|
| Scroll position listeners | Scroll-driven animations |
| IntersectionObserver for reveal | animation-timeline: view() |
| Sticky header shadow toggle | scroll-state(stuck: top) |
| Floating UI / Popper.js | Anchor positioning |
| Carousel prev/next/dots | ::scroll-button(), ::scroll-marker |
| Auto-expanding textarea | field-sizing: content |
| Staggered animation delays | sibling-index() |
max-height: 9999px hack | interpolate-size: allow-keywords |
| Parent element selection | :has() |
| Theme toggle logic | light-dark() + color-scheme |
| Tooltip/popover show/hide | Popover API + invoker commands |
| Color manipulation functions | color-mix(), relative color syntax |
For non-Baseline features, always feature-detect with @supports or use progressive enhancement. Check MDN or Baseline for current browser support.Anti-Patterns (CRITICAL)
| Anti-Pattern | Problem | Fix |
|---|---|---|
Overusing !important | Specificity arms race | Use @layer for cascade control |
Deep nesting (.a .b .c .d) | Fragile, DOM-coupled | Flat selectors, @scope |
IDs for styling (#header) | Too specific to override | Classes (.header) |
@media for component layout | Viewport-coupled, not reusable | Container queries |
| JS scroll listeners for effects | Janky, expensive | Scroll-driven animations |
| JS for tooltip positioning | Floating UI dependency | Anchor positioning |
| JS for carousel controls | Fragile, a11y issues | ::scroll-button, ::scroll-marker |
| JS for auto-expanding textarea | Unnecessary complexity | field-sizing: content |
max-height: 9999px for animation | Wrong duration, janky | interpolate-size: allow-keywords |
margin-left / padding-right | Breaks in RTL/vertical | Logical properties (margin-inline-start) |
rgba() with commas | Legacy syntax | rgb(r g b / a) space-separated |
appearance: none on selects | Removes ALL functionality | appearance: base-select |
| Preprocessor-only variables | Can't change at runtime | CSS custom properties |
| Preprocessor-only nesting | Extra build step dependency | Native CSS nesting |
| Preprocessor color functions | Can't respond to context | color-mix(), relative colors |
text-wrap: balance on paragraphs | Performance-heavy | Only headings/short text |
content-visibility above fold | Delays LCP rendering | Only off-screen sections |
Overusing will-change | Wastes GPU memory | Apply only to animating elements |
Reference Documentation
| File | Purpose |
|---|---|
| references/CASCADE.md | Nesting, @layer, @scope, cascade control, and CSS architecture |
| references/LAYOUT.md | Grid, Subgrid, Flexbox, Container Queries, and intrinsic sizing |
| references/SELECTORS.md | :has(), :is(), :where(), pseudo-elements, and state-based selection |
| references/COLOR.md | OKLCH, color-mix(), relative colors, light-dark(), and theming |
| references/TOKENS.md | @property, @function, if(), math functions, and design tokens |
| references/ANIMATION.md | @starting-style, interpolate-size, linear(), view transitions |
| references/SCROLL.md | Scroll-driven animations, scroll-state queries, native carousels |
| references/COMPONENTS.md | Customizable <select>, popover, anchor positioning, field-sizing |
| references/PERFORMANCE.md | content-visibility, typography, logical properties, accessibility |
| references/CHEATSHEET.md | Quick reference: browser support, legacy→modern patterns, units |
Sources
Official Specifications
- CSS Snapshot 2025 — W3C
- CSS Values and Units Level 5 —
if(),random(),sibling-index/count() - CSS Functions and Mixins Level 1 —
@function,@mixin - CSS Conditional Rules Level 5 — Scroll-state queries
- CSS Anchor Positioning
- CSS Overflow Level 5 — Scroll markers/buttons
Browser Vendor Blogs
- CSS Wrapped 2025 — Chrome DevRel
- Interop 2025 — WebKit
- What's New in Web UI (I/O 2025)
Reference
Animation — Making Things Move
Sources: CSS Transitions Level 2, CSS Animations Level 2, CSS View Transitions Level 2, CSS Easing Functions Level 2, Interop 2025
CSS now handles entry/exit animations, intrinsic size interpolation, custom easing curves, cross-document transitions, and responsive shape morphing — all without JavaScript.
Every animation in this file must respect `prefers-reduced-motion`. The universal reset appears first. Per-feature approaches appear inline.
---
Universal Reduced-Motion Reset
Apply at the top of every project. Override selectively for essential animations (e.g., progress spinners).
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Use 0.01ms not 0s — keeps transitionend/animationend events firing so JS listeners do not break.
---
Animation Strategy Decision Flowchart
flowchart TD
START(["What kind of animation?"]) --> Q1{"Element entering<br/>or exiting DOM?"}
Q1 -->|Yes| ENTRY["@starting-style +<br/>transition-behavior:<br/>allow-discrete"]
Q1 -->|No| Q2{"Animating to/from<br/>auto or intrinsic size?"}
Q2 -->|Yes| Q2a{"Need math on<br/>the size value?"}
Q2a -->|No| INTERP["interpolate-size:<br/>allow-keywords"]
Q2a -->|Yes| CALC["calc-size()"]
Q2 -->|No| Q3{"Custom easing<br/>(bounce/spring)?"}
Q3 -->|Yes| LINEAR["linear() easing"]
Q3 -->|No| Q4{"Page or view<br/>navigation?"}
Q4 -->|Yes| VT["View Transitions API"]
Q4 -->|No| Q5{"Shape morphing<br/>or path animation?"}
Q5 -->|Yes| SHAPE["shape() function"]
Q5 -->|No| Q6{"Simple A→B<br/>property change?"}
Q6 -->|Yes| TRANS["CSS transition"]
Q6 -->|No| KF["@keyframes animation"]
style START fill:#1a1a2e,color:#eee
style ENTRY fill:#0d3b66,color:#eee
style INTERP fill:#0d3b66,color:#eee
style CALC fill:#0d3b66,color:#eee
style LINEAR fill:#0d3b66,color:#eee
style VT fill:#0d3b66,color:#eee
style SHAPE fill:#0d3b66,color:#eee
style TRANS fill:#0d3b66,color:#eee
style KF fill:#0d3b66,color:#eee---
1. @starting-style — Entry Animations
Before @starting-style, there was no CSS way to animate an element's first render. Elements appeared at their final state instantly. Developers used requestAnimationFrame double-wrapping or class-toggle-after-a-frame hacks.
@starting-style defines the "before" state for first paint. The browser applies these styles on frame one, then transitions to normal styles.
/* ❌ Before: JavaScript hack for fade-in */element.style.display = 'block';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
element.classList.add('visible');
});
});/* ✅ After: Pure CSS entry animation */
.tooltip {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s, transform 0.3s;
@starting-style {
opacity: 0;
transform: translateY(-8px);
}
}Nested form (above) is preferred — keeps entry state co-located. Standalone form also works:
@starting-style {
.tooltip { opacity: 0; transform: translateY(-8px); }
}@starting-style alone handles elements always in the DOM. For display: none toggling (popovers, dialogs), pair with transition-behavior: allow-discrete.
Reduced motion: Keep fade, remove transform. @media (prefers-reduced-motion: reduce) { .tooltip { transition-duration: 0.15s; @starting-style { transform: none; } } }
Browser support: Baseline.
---
2. transition-behavior: allow-discrete
display and visibility are discrete properties — no intermediate values. Before allow-discrete, exit animations were impossible without JS to delay display: none.
How it works:
- Entry:
displayflips to visible at 0% of the transition. Element is visible immediately, animates in. - Exit:
displayflips to hidden at 100%. Element stays visible throughout, then disappears.
.panel {
transition: opacity 0.3s, display 0.3s allow-discrete;
}
.panel[hidden] {
opacity: 0;
display: none;
}The overlay Property
For top-layer elements (popovers, dialogs), transition overlay to keep them in the top layer during exit. Without it, a popover exits the top layer immediately and the exit animation is clipped.
[popover] {
transition: opacity 0.3s, display 0.3s, overlay 0.3s;
transition-behavior: allow-discrete;
}Browser support: Baseline.
---
3. The Canonical Entry/Exit Pattern
The unified pattern combining all three primitives. Use for popovers, dialogs, [hidden] toggling, and DOM insertion/removal.
@layer animations {
.animated-presence {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.3s ease,
transform 0.3s ease,
display 0.3s ease allow-discrete,
overlay 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(10px);
}
}
.animated-presence[hidden],
.animated-presence:not(:popover-open),
.animated-presence:not([open]) {
opacity: 0;
transform: translateY(10px);
}
}| Piece | Role | Without It |
|---|---|---|
@starting-style | "from" state on entry | Appears at final state instantly |
allow-discrete | display participates in transition | display: none instant, no exit animation |
overlay transition | Keeps top-layer visible during exit | Popover disappears before animation completes |
Dialog with Backdrop
dialog {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s, transform 0.3s,
display 0.3s allow-discrete, overlay 0.3s allow-discrete;
@starting-style { opacity: 0; transform: translateY(-20px); }
}
dialog:not([open]) { opacity: 0; transform: translateY(-20px); }
dialog::backdrop {
background: hsl(0 0% 0% / 0);
transition: background 0.3s, display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
dialog[open]::backdrop { background: hsl(0 0% 0% / 0.4); }
@starting-style {
dialog[open]::backdrop { background: hsl(0 0% 0% / 0); }
}Reduced motion: @media (prefers-reduced-motion: reduce) { .animated-presence { transition-duration: 0.01ms; } }
---
Entry/Exit Lifecycle State Diagram
stateDiagram-v2
direction LR
state "display: none" as Hidden
state "@starting-style applied" as Starting
state "Visible (final styles)" as Visible
state "Exit styles applied" as Exiting
[*] --> Hidden
Hidden --> Starting : display toggled to visible
Starting --> Visible : transition runs (opacity, transform)
Visible --> Visible : interactive / stable
Visible --> Exiting : removal triggered
Exiting --> Hidden : transition completes, display flips at 100%
Hidden --> [*]
note right of Starting
@starting-style provides the
"from" snapshot. display flips
at 0% (entry).
end note
note right of Exiting
Element remains visible throughout
transition. display flips at 100%.
overlay keeps top-layer.
end note---
4. interpolate-size: allow-keywords — Animate to/from auto
CSS could never transition height: auto, min-content, max-content, or fit-content. The workaround was the max-height: 9999px hack — always wrong because duration maps to 9999px, not actual content height.
/* ❌ max-height hack — duration is always wrong */
.accordion-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.5s ease;
}
.accordion.open .accordion-body {
max-height: 9999px; /* 200px panel finishes in ~10ms */
}/* ✅ interpolate-size — correct duration, smooth animation */
:root {
interpolate-size: allow-keywords;
}
.accordion-body {
height: 0;
overflow: hidden;
transition: height 0.4s ease;
}
.accordion.open .accordion-body {
height: auto; /* transitions to actual content height */
}Works with all intrinsic keywords: auto, min-content, max-content, fit-content.
Setting on :root is safe — only affects elements that already have transitions on size properties. Does not change layout or computed values. Set once, forget it.
Reduced motion: Keep functional open/close, just make it instant: transition-duration: 0.01ms.
Browser support: Feature-detect:
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
}---
5. calc-size() — Math on Intrinsic Sizes
interpolate-size enables interpolation but not arithmetic on keywords. calc-size() does.
.panel { height: calc-size(auto, size * 0.5); } /* half of auto */
.tag { width: calc-size(fit-content, size + 2rem); } /* fit-content + padding */
.cell { width: calc-size(min-content, max(size, 100px)); } /* floor */First argument: sizing keyword. Second: calculation using size as the resolved value.
Animatable when both states use the same keyword:
.drawer {
height: calc-size(auto, size * 0);
overflow: hidden;
transition: height 0.3s ease;
}
.drawer.open {
height: calc-size(auto, size * 1);
}| Scenario | Use |
|---|---|
Animate height: 0 to height: auto | interpolate-size: allow-keywords |
Animate to 50% of auto height | calc-size(auto, size * 0.5) |
Add padding to fit-content | calc-size(fit-content, size + 1rem) |
Clamp min-content with a floor | calc-size(min-content, max(size, 80px)) |
Browser support: Always feature-detect.
---
6. linear() Easing — Custom Curves
cubic-bezier() is confined to a unit box — no bounce, spring, or overshoot. linear() defines unlimited control points. Values above 1 create overshoot; below 0 create undershoot.
.bounce {
transition: transform 0.6s linear(
0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141, 0.191,
0.25, 0.316, 0.391, 0.472, 0.562, 0.66, 0.765, 0.878,
1, 0.956, 0.922, 0.898, 0.883, 0.878, 0.883, 0.898,
0.922, 0.956, 1, 0.988, 0.981, 0.978, 0.981, 0.988, 1
);
}With explicit positions: linear(0, 0.5 25%, 1 50%, 0.8 75%, 1)
Store as custom properties for reuse:
:root {
--ease-bounce: linear(0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141,
0.191, 0.25, 0.316, 0.391, 0.472, 0.562, 0.66, 0.765, 0.878,
1, 0.956, 0.922, 0.898, 0.883, 0.878, 0.883, 0.898, 0.922,
0.956, 1, 0.988, 0.981, 0.978, 0.981, 0.988, 1);
--ease-spring: linear(0, 0.009, 0.035, 0.078, 0.141, 0.223, 0.326,
0.45, 0.594, 0.758, 0.938, 1.026, 1.063, 1.064, 1.042, 1.007,
0.968, 0.938, 0.923, 0.925, 0.942, 0.966, 0.99, 1.006, 1.012,
1.008, 0.998, 0.99, 0.988, 0.992, 0.998, 1.002, 1.003, 1.001, 1);
}Do not hand-write control points. Use:
- linear-easing-generator.netlify.app — paste a JS easing function, get
linear()output - easingwizard.com — visual editor
Reduced motion: Spring/bounce imply spatial motion. Replace with simple ease or remove.
Browser support: Baseline.
---
7. View Transitions API
Animate state changes across the page — navigations, DOM updates, layout shifts. Browser captures a "before" snapshot, you apply the change, browser crossfades to "after."
Same-Document (Level 1)
document.startViewTransition(() => updateDOM());Name specific elements for independent transitions:
.hero-image { view-transition-name: hero; }
.page-title { view-transition-name: title; }
::view-transition-old(hero) { animation: fade-out 0.3s; }
::view-transition-new(hero) { animation: fade-in 0.3s; }
::view-transition-group(hero) { animation-duration: 0.4s; }Names must be unique on the page at any given time.
Cross-Document (Level 2)
Opt in on both source and destination pages:
@view-transition { navigation: auto; }Matching view-transition-name values across pages create shared-element transitions.
view-transition-class — Bulk Styling
.card { view-transition-class: card; }
::view-transition-group(*.card) {
animation-duration: 0.3s;
animation-timing-function: var(--ease-spring);
}view-transition-name: match-element
Auto-assigns unique names — essential for reorderable lists:
.list-item { view-transition-name: match-element; }Nested Groups
Child transitions animate independently within a parent group:
.card { view-transition-name: card-1; }
.card img { view-transition-name: card-1-img; }
::view-transition-group(card-1-img) {
view-transition-group: card-1; /* nest inside parent */
}Reduced motion:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.01ms !important;
}
}Browser support: Level 1 Baseline. Level 2 (cross-document, view-transition-class, match-element) is Interop 2025, shipping Chrome/Safari/Firefox.
---
8. shape() Function — Responsive Shapes
path() uses SVG coordinates — fixed pixels, not responsive. shape() uses CSS units and percentages.
/* ❌ path() — fixed pixel coordinates */
.clip { clip-path: path('M 0 0 L 200 0 L 200 150 Q 100 200 0 150 Z'); }
/* ✅ shape() — responsive to element size */
.clip {
clip-path: shape(from 0% 0%, line to 100% 0%, line to 100% 70%,
curve to 0% 70% with 50% 100%, close);
}Commands: line to, curve to ... with, smooth to, arc to ... of, hline to, vline to.
Animatable when both states have the same number and type of commands:
.morph {
clip-path: shape(from 0% 0%, line to 100% 0%,
line to 100% 100%, line to 0% 100%, close);
transition: clip-path 0.5s var(--ease-spring);
}
.morph:hover {
clip-path: shape(from 10% 0%, line to 90% 0%,
line to 100% 100%, line to 0% 100%, close);
}Reduced motion: transition: none; — show final state.
Browser support: Feature-detect with @supports.
---
9. corner-shape — Non-Rounded Corners
border-radius only makes circles/ellipses. corner-shape adds geometric alternatives.
.card { border-radius: 20px; corner-shape: squircle; } /* superellipse */
.tag { border-radius: 8px; corner-shape: bevel; } /* 45-deg chamfer */
.badge { border-radius: 12px; corner-shape: notch; } /* inward rectangle */
.frame { border-radius: 16px; corner-shape: scoop; } /* concave curve */corner-shape uses the border-radius value to determine treatment size.
Per-corner: corner-shape: squircle bevel squircle bevel; (TL, TR, BR, BL).
Animatable between shapes:
.card {
border-radius: 20px;
corner-shape: round;
transition: corner-shape 0.4s ease;
}
.card:hover { corner-shape: squircle; }Reduced motion: transition: none; corner-shape: squircle; (apply preferred shape statically).
Browser support: Experimental. Always feature-detect and provide border-radius fallback:
@supports (corner-shape: squircle) {
.card { corner-shape: squircle; }
}---
10. prefers-reduced-motion — The Accessibility Contract
Every animation must have a reduced-motion path. Motion triggers vestibular disorders, nausea, and seizures.
Strategy: Universal Reset + Selective Override
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Re-enable essential animations */
.spinner {
animation-duration: 1s !important;
animation-iteration-count: infinite !important;
}
}Per-Feature Summary
| Feature | Reduced-Motion Approach |
|---|---|
@starting-style | Keep opacity fade (short), remove transform |
| Entry/exit transitions | transition-duration: 0.01ms |
interpolate-size | Instant expand/collapse (functional) |
linear() bounce/spring | Replace with ease or remove |
| View Transitions | animation-duration: 0.01ms on pseudo-elements |
shape() morphing | transition: none, show final state |
corner-shape | transition: none, apply shape statically |
Motion-Aware Custom Properties
:root {
--motion-duration: 0.3s;
--motion-distance: 10px;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 0.01ms;
--motion-distance: 0;
}
}
.element {
transition: transform var(--motion-duration) ease;
@starting-style { transform: translateY(var(--motion-distance)); }
}For non-Baseline features, always feature-detect with @supports or use progressive enhancement. Check MDN or Baseline for current browser support.---
Anti-Patterns
| Anti-Pattern | Problem | Modern Replacement |
|---|---|---|
max-height: 9999px | Wrong timing, always janky | interpolate-size: allow-keywords |
rAF double-wrap for entry | Fragile timing hack | @starting-style |
JS setTimeout to delay display: none | Race conditions, flickering | transition-behavior: allow-discrete |
cubic-bezier() for bounce/spring | Cannot exceed unit box | linear() easing |
Fixed-pixel SVG path() | Not responsive | shape() with CSS units |
| JS page transition libraries | Bundle size, complexity | View Transitions API |
Ignoring prefers-reduced-motion | Accessibility violation | Universal reset + selective override |
animation: none for reduced motion | Breaks JS event listeners | animation-duration: 0.01ms |
will-change on everything | Wastes GPU memory | Only on elements being animated |
Cascade Control — Controlling Which Styles Win
Sources:
- CSS Cascading and Inheritance Level 5 — W3C (@layer)- CSS Cascading and Inheritance Level 6 — W3C (@scope, proximity)- CSS Nesting Module — W3C
- CSS Conditional Rules Level 5 — W3C (@supports,@media)
- CSS Display Level 4 — W3C (reading-flow)- MDN: CSS Cascade — Mozilla
How the Cascade Resolves Styles
Every style conflict resolves through this hierarchy. The first decisive step wins:
flowchart TD
A["Two+ declarations target the same property"] --> B{"1. Transitions active?"}
B -- "Yes" --> B1["Transition value wins"]
B -- "No" --> C{"2. !important?"}
C -- "Yes" --> C1["Highest-origin !important wins<br>(layer order INVERTS for !important)"]
C -- "No" --> D{"3. @layer order?"}
D -- "Different layers" --> D1["Unlayered beats all layers.<br>Among layers: LAST declared wins.<br>(For !important: FIRST layer wins)"]
D -- "Same layer" --> E{"4. Specificity?"}
E -- "Different" --> E1["Higher specificity wins<br>(ID > class > element)"]
E -- "Tied" --> F{"5. @scope proximity?"}
F -- "Different" --> F1["Closer scope root wins"]
F -- "Same" --> G{"6. Source order?"}
G --> G1["Last declaration wins"]
style B1 fill:#2d5016,color:#fff
style C1 fill:#2d5016,color:#fff
style D1 fill:#2d5016,color:#fff
style E1 fill:#2d5016,color:#fff
style F1 fill:#2d5016,color:#fff
style G1 fill:#2d5016,color:#fffMemorize: Transitions > Importance > Layers > Specificity > Proximity > Source Order.
---
1. Native CSS Nesting
Use native nesting to co-locate related rules. Baseline in all modern browsers. Stop using preprocessor nesting for new projects.
Rules:
- Nested rules implicitly start with
&(the parent selector). - Use explicit
&to place the parent reference elsewhere (a&,.parent &). - Bare element selectors (
div,span) work directly without&. - Nesting produces the same specificity as the equivalent flat selector.
- Nest at most 2-3 levels deep. Deeper nesting creates fragile, DOM-coupled selectors.
Nest when rules are subordinate to a parent (states, pseudo-elements, media queries). Keep flat when selectors are independent concepts.
/* ❌ Preprocessor — requires build step, proprietary syntax */
.card {
padding: 1rem;
&__title { font-size: 1.25rem; }
&:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.15); }
}/* ✅ Native CSS nesting — no build step */
.card {
padding: 1rem;
.card-title { font-size: 1.25rem; }
&:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.15); }
}& Placement Patterns
.button {
background: steelblue;
&:hover { background: darkblue; } /* .button:hover */
.icon { width: 1em; } /* .button .icon */
.wrapper & { margin: 0; } /* .wrapper .button */
a& { text-decoration: none; } /* a.button */
}Nesting @media Inside a Rule
.grid {
display: grid;
grid-template-columns: 1fr;
@media (width >= 768px) { grid-template-columns: repeat(2, 1fr); }
@media (width >= 1200px) { grid-template-columns: repeat(4, 1fr); }
}---
2. Cascade Layers (@layer)
Define layer order once at the top of your CSS and specificity wars disappear. A declaration in a later layer always beats an earlier layer, regardless of selector specificity.
The Core Insight
Without layers, overriding styles requires higher specificity (leading to !important arms races) or later source order (fragile). Layers decouple "which styles win" from "how specific the selector is."
Layer Ordering
/* This single line controls your entire cascade architecture */
@layer reset, base, components, utilities;Priority (lowest to highest): reset < base < components < utilities. Styles in utilities beat components regardless of specificity.
Layer Stacking
block-beta
columns 1
block:cascade["Cascade Priority (top wins)"]:1
U["Unlayered styles (highest — beats ALL layers)"]
L4["@layer utilities (last declared)"]
L3["@layer components"]
L2["@layer base"]
L1["@layer reset (first declared = lowest)"]
end
style U fill:#7c2d12,color:#fff
style L4 fill:#1e40af,color:#fff
style L3 fill:#1e3a5f,color:#fff
style L2 fill:#1a3350,color:#fff
style L1 fill:#172540,color:#fffCritical: Unlayered styles beat all layered styles. Use this intentionally for page-specific overrides. Avoid accidentally leaving styles unlayered.
Specificity Hacks vs @layer
/* ❌ Specificity arms race — fragile, escalates */
.nav .menu .item a { color: gray; }
.nav .menu .item a.active { color: blue; }
#main-nav .nav .menu .item a.active { color: blue !important; }/* ✅ @layer — simple selectors, architectural control */
@layer base, components, utilities;
@layer base { a { color: gray; } }
@layer components { .active { color: blue; } }
@layer utilities { .text-primary { color: blue; } }Three Ways to Define Layers
/* 1. Block syntax */
@layer components {
.card { padding: 1rem; }
}
/* 2. Import into a layer */
@import url("reset.css") layer(reset);
@import url("vendor.css") layer(vendor);
/* 3. Anonymous layer (cannot append to later — use sparingly) */
@layer { body { margin: 0; } }Sub-layers
Use dot notation when a layer needs internal ordering:
@layer components.card, components.button, components.modal;
@layer components.card { .card { border: 1px solid #ddd; } }
@layer components.modal { .modal .card { border: none; } }Sub-layers resolve within their parent. components.modal never competes directly with utilities.
!important Inverts Layer Order
When !important is used, layer priority inverts. The first-declared layer's !important beats the last-declared layer's !important. This lets resets protect critical styles:
@layer reset, components, utilities;
@layer reset {
*, *::before, *::after { box-sizing: border-box !important; }
/* Beats utilities !important because reset is first */
}Avoid !important in layered architectures. The layer order should handle priority.
Recommended Architecture
@layer reset, base, layout, components, utilities;
@import url("modern-reset.css") layer(reset);
@layer base {
body { font-family: system-ui, sans-serif; line-height: 1.6; }
h1, h2, h3 { line-height: 1.2; text-wrap: balance; }
}
@layer layout {
.page { display: grid; grid-template-rows: auto 1fr auto; min-height: 100dvh; }
}
@layer components {
.card { padding: 1rem; border-radius: 8px; }
.button { padding: 0.5em 1em; border: none; cursor: pointer; }
}
@layer utilities {
.visually-hidden {
clip: rect(0 0 0 0); clip-path: inset(50%);
height: 1px; overflow: hidden;
position: absolute; white-space: nowrap; width: 1px;
}
}---
3. @scope — Proximity-Based Scoping
Use @scope to limit where styles apply and to resolve conflicts by DOM proximity. Use it when component styles should not leak into nested sub-components.
Basic Scoping
@scope (.card) {
h2 { font-size: 1.25rem; }
p { color: #555; }
.actions { display: flex; gap: 1rem; }
}@scope does not add specificity. The advantage is containment and proximity.
Donut Scoping — Excluding Inner Regions
Use to to stop matching before a nested boundary. This prevents parent styles from leaking into child components:
@scope (.card) to (.card) {
p { color: #333; }
/* Does NOT match <p> inside a nested .card */
}<div class="card">
<p>Styled by the scope.</p>
<div class="card">
<p>NOT styled — excluded by the "to .card" boundary.</p>
</div>
</div>Use donut scoping for recursive/nested component patterns (comments, tree views, nested cards).
Proximity Resolution
When two @scope blocks target the same element with equal specificity, the closer scope root wins. This is checked after specificity but before source order:
@scope (.light-theme) { p { color: #333; background: #fff; } }
@scope (.dark-theme) { p { color: #eee; background: #222; } }<div class="light-theme">
<p>Light (closest root is .light-theme).</p>
<div class="dark-theme">
<p>Dark (closest root is .dark-theme — proximity wins).</p>
<div class="light-theme">
<p>Light again (inner .light-theme is closest).</p>
</div>
</div>
</div>Proximity makes nested theming work without specificity tricks.
Inline <style> Scoping
Use @scope without a selector inside <style> to scope to the parent element:
<div class="widget">
<style>
@scope {
p { color: navy; }
.title { font-weight: bold; }
}
</style>
<p class="title">Scoped to this widget only.</p>
</div>Theming with Donut Scopes
Combine donut scoping with custom properties to prevent theme bleed-through:
.light-theme { --surface: #fff; --text: #1a1a1a; }
.dark-theme { --surface: #1a1a1a; --text: #e5e5e5; }
@scope (.light-theme) to (.dark-theme) {
.card { background: var(--surface); color: var(--text); }
}
@scope (.dark-theme) to (.light-theme) {
.card { background: var(--surface); color: var(--text); }
}The Full Cascade Hierarchy (with Scope)
1. Importance — !important (with inverted layer order) 2. Layers — later @layer beats earlier; unlayered beats all 3. Specificity — ID (1,0,0) > class (0,1,0) > element (0,0,1) 4. Proximity — closer @scope root wins 5. Source order — last declaration wins
---
4. @supports — Feature Queries
Use @supports for features not yet Baseline. Do not wrap Baseline features in @supports — it adds unnecessary complexity.
Feature Detection Patterns
/* Fallback */
.grid { display: flex; flex-wrap: wrap; }
/* Enhance when available */
@supports (grid-template-columns: subgrid) {
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.grid > * { display: grid; grid-template-rows: subgrid; }
}Detecting Modern Features
@supports (selector(:has(a))) { /* :has() available */ }
@supports (container-type: inline-size) { /* container queries */ }
@supports (anchor-name: --tip) { /* anchor positioning */ }
@supports (reading-flow: grid-rows) { /* reading-flow */ }Negation and Combination
@supports not (display: grid) { .layout { display: flex; } }
@supports (display: grid) and (grid-template-columns: subgrid) { /* both */ }
@supports (overflow: clip) or (overflow: hidden) { /* either */ }Progressive Enhancement Pattern
Structure as enhancement layers — base works everywhere, enhancements add capabilities:
.card { padding: 1rem; background: white; }
@supports (color: oklch(0.7 0.15 200)) {
.card { background: oklch(0.98 0.01 200); }
}
@supports (selector(@scope (.a))) {
@scope (.card) to (.card) { p { margin-block: 0.5em; } }
}---
5. @media Range Syntax
Use modern range syntax for media queries. More readable, less error-prone, Baseline in all modern browsers.
/* ❌ Legacy — verbose, off-by-one risk */
@media (min-width: 768px) and (max-width: 1199px) {
.sidebar { width: 250px; }
}/* ✅ Modern — reads like math */
@media (768px <= width < 1200px) { .sidebar { width: 250px; } }
@media (width >= 1200px) { .sidebar { width: 300px; } }Common Patterns
@media (width >= 768px) { /* tablet and up */ }
@media (width < 768px) { /* mobile only */ }
@media (768px <= width < 1200px) { /* tablet only */ }
@media (height >= 600px) { /* sufficient vertical space */ }
@media (aspect-ratio > 16/9) { /* ultrawide */ }Combine with nesting to co-locate responsive rules:
.nav {
display: flex;
flex-direction: column;
@media (width >= 768px) { flex-direction: row; gap: 2rem; }
}---
6. reading-flow — Tab Order Matching Visual Layout
When CSS reorders content visually (order, grid-row, flex-direction: row-reverse), keyboard tab order still follows DOM order. reading-flow fixes this accessibility gap.
/* ❌ Visual order differs from tab order */
.grid { display: grid; }
.grid .featured { order: -1; /* Visually first, tabbed last */ }/* ✅ Tab order follows visual layout */
.grid {
display: grid;
reading-flow: grid-rows;
}
.flex-container {
display: flex;
reading-flow: flex-visual;
}| Value | Behavior |
|---|---|
normal | Tab follows DOM order (default) |
flex-visual | Follows visual flex item order |
flex-flow | Follows flex-flow direction |
grid-rows | Follows visual grid rows |
grid-columns | Follows visual grid columns |
grid-order | Follows order property in grid |
Apply whenever CSS changes visual order. Feature-detect first:
@supports (reading-flow: grid-rows) {
.reordered-grid { reading-flow: grid-rows; }
}---
Putting It All Together
@layer reset, base, layout, components, utilities;
@import url("reset.css") layer(reset);
@layer base {
:root { --surface: oklch(0.99 0 0); --text: oklch(0.15 0 0); }
body { font-family: system-ui, sans-serif; color: var(--text); }
}
@layer layout {
.page {
display: grid; grid-template-rows: auto 1fr auto; min-height: 100dvh;
@media (width >= 1200px) { grid-template-columns: 250px 1fr; }
}
}
@layer components {
@scope (.card) to (.card) {
:scope { padding: 1rem; background: var(--surface); }
h2 { font-size: 1.25rem; }
.actions { display: flex; gap: 0.5rem; margin-block-start: 1rem; }
}
}
@layer utilities {
.sr-only {
clip: rect(0 0 0 0); clip-path: inset(50%);
height: 1px; overflow: hidden; position: absolute;
white-space: nowrap; width: 1px;
}
}
/* Unlayered — page-specific overrides (beats ALL layers) */
.page-home .card { border: 2px solid oklch(0.6 0.15 250); }Decision Guide
| Situation | Tool |
|---|---|
| Control which stylesheet wins | @layer |
| Prevent styles leaking to child components | @scope with donut scoping |
| Nested themes without specificity tricks | @scope proximity |
| Co-locate responsive + base styles | Native nesting + @media |
| Fallbacks for newer features | @supports |
| Readable breakpoints | @media range syntax |
| Fix tab order after visual reordering | reading-flow |
Override layered styles without !important | Unlayered styles |
| Protect critical resets | !important in first-declared layer |
Anti-Patterns
| Do Not | Instead |
|---|---|
| Leave styles unlayered in a layered codebase | Place all styles in an explicit layer |
Use !important to win specificity battles | Use @layer ordering |
| Nest selectors deeper than 3 levels | Flatten or use @scope |
Wrap Baseline features in @supports | Use them directly |
Use min-width/max-width media syntax | Range syntax (width >= 768px) |
Reorder visually without reading-flow | Add reading-flow or fix DOM order |
| Create anonymous layers for important styles | Name all layers |
| Rely on source order for critical overrides | Use explicit @layer ordering |
Quick Reference Cheatsheet
See SKILL.md for full source list.
---
Cascade Resolution Hierarchy
flowchart TB
subgraph WINS["Highest Priority"]
style WINS fill:#2d6a2d,color:#fff
T["1. Transitions<br/>(active transition wins)"]
end
subgraph IMPORTANT["!important (reversed origin)"]
style IMPORTANT fill:#8b2500,color:#fff
I1["2a. UA !important"]
I2["2b. User !important"]
I3["2c. Author !important"]
end
subgraph LAYERS["Cascade Layers"]
style LAYERS fill:#1a4a6e,color:#fff
L1["3. @layer order<br/>(last declared layer wins)"]
L2["4. Unlayered styles<br/>(beat ALL layers)"]
end
subgraph SPECIFICITY["Specificity & Proximity"]
style SPECIFICITY fill:#4a3560,color:#fff
S["5. Specificity<br/>(ID > class > element)"]
P["6. @scope proximity<br/>(closer root wins)"]
end
subgraph FALLBACK["Lowest Priority"]
style FALLBACK fill:#555,color:#fff
O["7. Source order<br/>(later declaration wins)"]
end
T --> I1 --> I2 --> I3 --> L1 --> L2 --> S --> P --> OKey rule: Unlayered styles > Last layer > ... > First layer. Define order once:
@layer reset, base, components, utilities;
/* reset = lowest priority, unlayered = highest */Layer priority shorthand:
Unlayered > utilities > components > base > resetSpecificity weight: (ID, CLASS, ELEMENT) -- :is()/:not()/:has() take highest argument. :where() always 0.
---
Layout Decision Flow
flowchart TB
START(["What layout do I need?"])
D2D{"Need rows<br/>AND columns?"}
D1D{"Need 1D<br/>row or column?"}
DCQ{"Component must<br/>adapt to its<br/>container?"}
DMAS{"Masonry /<br/>waterfall?"}
DSUB{"Children must<br/>align to parent<br/>grid tracks?"}
DCENTER{"Just centering<br/>one element?"}
GRID["CSS Grid"]
SUBGRID["Grid + Subgrid"]
FLEX["Flexbox"]
CQ["Container Query<br/>+ Grid / Flex"]
LANES["grid-lanes<br/>(experimental)"]
INTRINSIC["fit-content /<br/>min-content /<br/>stretch"]
CENTER["align-content: center<br/>(no wrapper needed)"]
START --> D2D
D2D -- Yes --> DSUB
DSUB -- Yes --> SUBGRID
DSUB -- No --> DMAS
DMAS -- Yes --> LANES
DMAS -- No --> GRID
D2D -- No --> D1D
D1D -- Yes --> DCQ
DCQ -- Yes --> CQ
DCQ -- No --> FLEX
D1D -- No --> DCENTER
DCENTER -- Yes --> CENTER
DCENTER -- No --> INTRINSIC
style GRID fill:#1a6e3a,color:#fff
style SUBGRID fill:#1a6e3a,color:#fff
style FLEX fill:#1a4a6e,color:#fff
style CQ fill:#4a3560,color:#fff
style LANES fill:#8b6500,color:#fff
style INTRINSIC fill:#555,color:#fff
style CENTER fill:#2d6a2d,color:#fff---
Legacy to Modern Quick Upgrades
1. Color syntax
/* ❌ */ background: rgba(100, 50, 200, 0.5);
/* ✅ */ background: rgb(100 50 200 / 0.5);2. Physical to logical
/* ❌ */ margin-left: 1rem;
/* ✅ */ margin-inline-start: 1rem;3. Preprocessor nesting to native
/* ❌ */ .card { .title { /* requires Sass */ } }
/* ✅ */ .card { .title { color: inherit; } } /* native CSS nesting */4. Media query syntax
/* ❌ */ @media (min-width: 768px) { }
/* ✅ */ @media (width >= 768px) { }5. Specificity wars to layers
/* ❌ */ .btn { color: red !important; }
/* ✅ */ @layer base { .btn { color: red; } }6. JS parent selection to :has()
/* ❌ */ /* JS: card.querySelector('img') && card.classList.add('has-img') */
/* ✅ */ .card:has(img) { grid-template-rows: 200px 1fr; }7. JS scroll listener to scroll-driven animation
/* ❌ */ /* JS: window.addEventListener('scroll', updateProgress) */
/* ✅ */ .progress { animation: grow linear; animation-timeline: scroll(); }8. JS tooltip position to anchor positioning
/* ❌ */ /* JS: Floating UI / Popper.js for tooltip placement */
/* ✅ */ .tooltip { position: fixed; position-anchor: --trigger; inset-area: block-start; }9. Max-height hack to interpolate-size
/* ❌ */ .panel { max-height: 9999px; transition: max-height 0.3s; }
/* ✅ */ .panel { interpolate-size: allow-keywords; transition: height 0.3s; height: auto; }10. Sass variables to custom properties
/* ❌ */ $primary: #3b82f6; /* Sass — compile-time only */
/* ✅ */ :root { --primary: oklch(0.59 0.2 260); } /* runtime, inherits, responds to context */11. Sass color functions to color-mix / relative color
/* ❌ */ darken($primary, 10%); /* Sass only */
/* ✅ */ color-mix(in oklch, var(--primary), black 20%);
/* ✅ */ oklch(from var(--primary) calc(l - 0.1) c h); /* relative color */12. appearance: none to base-select
/* ❌ */ select { appearance: none; } /* destroys all native behavior */
/* ✅ */ select { appearance: base-select; } /* styleable AND functional */13. JS auto-resize textarea to field-sizing
/* ❌ */ /* JS: textarea.style.height = textarea.scrollHeight + 'px' */
/* ✅ */ textarea { field-sizing: content; }14. Manual stagger delays to sibling-index()
/* ❌ */ li:nth-child(1) { --d: 0ms; } li:nth-child(2) { --d: 50ms; } /* ... */
/* ✅ */ li { transition-delay: calc(sibling-index() * 50ms); }15. Hex colors to oklch
/* ❌ */ color: #3b82f6;
/* ✅ */ color: oklch(0.59 0.2 260); /* perceptually uniform, P3 gamut */---
CSS Replaces JavaScript
| JS Pattern | CSS Replacement |
|---|---|
| Scroll position listeners | animation-timeline: scroll() |
| IntersectionObserver for reveal | animation-timeline: view() |
| Sticky header shadow toggle | scroll-state(stuck: top) |
| Floating UI / Popper.js | Anchor positioning (position-anchor, inset-area) |
| Carousel prev/next/dots | ::scroll-button(), ::scroll-marker |
| Auto-expanding textarea | field-sizing: content |
| Staggered animation delays | sibling-index() in calc() |
max-height: 9999px hack | interpolate-size: allow-keywords |
| Parent element selection | :has() |
| Theme toggle logic | light-dark() + color-scheme |
| Tooltip/popover show/hide | Popover API + invoker commands |
| Color manipulation | color-mix(), relative color syntax |
---
Property Quick Reference by Category
Layout
| Property / At-Rule | Values / Syntax | Purpose |
|---|---|---|
display | grid, flex, contents, none | Layout mode |
grid-template-columns | repeat(auto-fill, minmax(250px, 1fr)) | Responsive columns |
grid-template-rows | subgrid | Inherit parent tracks |
grid-auto-flow | dense | Fill gaps automatically |
container-type | inline-size, size, normal, scroll-state | Enable container queries |
container-name | <custom-ident> | Name the container |
@container | (inline-size >= 400px) | Query container dimensions |
@container style() | style(--theme: dark) | Query container custom props |
align-content | center (on block elements) | Vertical centering without flex/grid |
place-items | center | Shorthand for align + justify items |
gap | 1rem, 1rem 2rem | Row and column gap |
Cascade & Scope
| Syntax | Purpose | Key Behavior |
|---|---|---|
@layer reset, base, components; | Declare layer order | First = lowest priority |
@layer base { ... } | Add rules to a layer | Unlayered beats all layers |
@scope (.card) to (.card-footer) | Limit style reach | Proximity wins over specificity |
.parent { .child { } } | Native nesting | & optional before classes |
.parent { & .child { } } | Explicit nesting | & required before element selectors |
.parent { @media (...) { } } | Nested media query | Scoped to parent context |
@import url() layer(name) | Import into a layer | Third-party CSS isolation |
Color
| Function | Syntax | Notes |
|---|---|---|
oklch() | oklch(L C H) or oklch(L C H / A) | L: 0-1, C: 0-0.4, H: 0-360 |
oklab() | oklab(L a b) or oklab(L a b / A) | Perceptual, good for gradients |
color-mix() | color-mix(in oklch, color1, color2 %) | Mix two colors in any space |
light-dark() | light-dark(lightVal, darkVal) | Requires color-scheme set |
| Relative color | oklch(from var(--c) calc(l - 0.1) c h) | Derive colors from base |
color() | color(display-p3 1 0 0) | Wide-gamut P3 colors |
Animation & Transitions
| Property / At-Rule | Syntax | Purpose |
|---|---|---|
@starting-style | @starting-style { .el { opacity: 0; } } | Entry animation initial state |
transition-behavior | allow-discrete | Animate display, overlay |
interpolate-size | allow-keywords | Animate to/from auto |
animation-timeline | scroll(), view(), --name | Scroll-driven animations |
animation-range | entry 0% entry 100% | Visible range for view() |
view-transition-name | <custom-ident> | Opt element into view transition |
linear() | linear(0, 0.5 25%, 1) | Custom easing with stops |
overlay | auto, none | Keep element in top layer during exit |
Selectors
| Selector | Specificity | Purpose |
|---|---|---|
:has() | Highest in list | Parent/relational selection |
:is() | Highest in list | Grouping (carries specificity) |
:where() | Always 0 | Grouping (zero specificity) |
:not() | Highest in list | Negation with selector list |
:focus-visible | (0,1,0) | Keyboard-only focus ring |
:user-valid / :user-invalid | (0,1,0) | Form validation after interaction |
:nth-child(... of S) | (0,1,0) + S | Filter nth-child by selector |
---
Color Function Syntax
| Function | Parameters | Example |
|---|---|---|
oklch(L C H) | L: 0-1 lightness, C: 0-0.4 chroma, H: 0-360 hue | oklch(0.7 0.15 145) |
oklch(L C H / A) | + alpha 0-1 | oklch(0.7 0.15 145 / 0.5) |
oklab(L a b) | L: 0-1, a/b: -0.4 to 0.4 | oklab(0.7 -0.1 0.1) |
color(space R G B) | space: srgb, display-p3, rec2020 | color(display-p3 1 0.5 0) |
color-mix(in space, c1, c2 %) | Any color space, two colors, optional % | color-mix(in oklch, red, blue 30%) |
light-dark(light, dark) | Two color values | light-dark(#fff, #1a1a1a) |
Relative: oklch(from ...) | from <color> L C H with calc() | oklch(from var(--c) calc(l+0.1) c h) |
rgb(R G B / A) | Space-separated, no commas | rgb(100 200 50 / 0.8) |
hsl(H S L / A) | H: deg, S/L: % | hsl(260 80% 50% / 0.9) |
OKLCH Hue Reference
| Hue Range | Color | Common Use |
|---|---|---|
| 0-30 | Pink/Red | Error, danger |
| 30-70 | Orange/Yellow | Warning |
| 70-140 | Yellow/Green | Success |
| 140-200 | Green/Cyan | Confirmation |
| 200-270 | Blue | Primary, info, links |
| 270-330 | Purple/Magenta | Accent |
| 330-360 | Pink/Red | Back to error |
Common Color Recipes
/* Auto dark/light theme */
:root { color-scheme: light dark; }
body { color: light-dark(#1a1a1a, #e5e5e5); background: light-dark(#fff, #111); }
/* Palette from one base color */
--base: oklch(0.6 0.2 260);
--lighter: oklch(from var(--base) calc(l + 0.2) c h);
--darker: oklch(from var(--base) calc(l - 0.2) c h);
--muted: oklch(from var(--base) l calc(c * 0.5) h);
--transparent: oklch(from var(--base) l c h / 0.2);
/* Semi-transparent overlay */
--overlay: color-mix(in oklch, var(--base), transparent 60%);---
Unit Reference
Viewport Units
| Unit | Measures | When to Use |
|---|---|---|
dvh / dvw | Dynamic viewport (adjusts for mobile URL bar) | Full-screen hero sections, mobile layouts |
svh / svw | Small viewport (URL bar visible) | Minimum safe area sizing |
lvh / lvw | Large viewport (URL bar hidden) | Maximum available space |
dvb / dvi | Dynamic block/inline (logical) | Writing-mode-aware viewports |
vmin / vmax | Smaller/larger of vw/vh | Responsive typography |
Container Units
| Unit | Measures | When to Use |
|---|---|---|
cqw | 1% of container's width | Component-relative sizing |
cqh | 1% of container's height | Requires container-type: size |
cqi | 1% of container's inline size | Writing-mode-aware (preferred) |
cqb | 1% of container's block size | Writing-mode-aware block |
cqmin / cqmax | Smaller/larger of cqi/cqb | Adaptive within container |
Other Modern Units
| Unit | Measures | When to Use |
|---|---|---|
lh | Computed line-height of element | Vertical spacing relative to text |
rlh | Computed line-height of root | Consistent baseline grid |
cap | Cap height of font | Optical alignment with text |
rex | Root element's x-height | Consistent small sizing |
ic | Width of CJK ideograph | CJK typography spacing |
---
Logical Properties Mapping
| Physical | Logical | Axis |
|---|---|---|
width | inline-size | Inline |
height | block-size | Block |
min-width | min-inline-size | Inline |
max-height | max-block-size | Block |
margin-top | margin-block-start | Block |
margin-bottom | margin-block-end | Block |
margin-left | margin-inline-start | Inline |
margin-right | margin-inline-end | Inline |
padding-left | padding-inline-start | Inline |
padding-right | padding-inline-end | Inline |
border-top | border-block-start | Block |
border-bottom | border-block-end | Block |
top | inset-block-start | Block |
bottom | inset-block-end | Block |
left | inset-inline-start | Inline |
right | inset-inline-end | Inline |
text-align: left | text-align: start | Inline |
float: left | float: inline-start | Inline |
border-radius: 8px 0 0 8px | border-start-start-radius: 8px; border-end-start-radius: 8px | Both |
Shorthands: margin-block, margin-inline, padding-block, padding-inline, inset-block, inset-inline.
---
Grid Pattern Recipes
Responsive auto-fill columns
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr));
gap: 1rem;
}Holy grail layout
body {
display: grid;
grid-template: "header header" auto
"sidebar main" 1fr
"footer footer" auto / 250px 1fr;
min-block-size: 100dvh;
}Subgrid card alignment
.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3; /* title, content, footer */
}Full-bleed inside constrained parent
.full-bleed {
inline-size: 100vw;
margin-inline: calc(50% - 50vw);
}---
@supports Feature Detection
| Feature | @supports Test |
|---|---|
| Container queries | (container-type: inline-size) |
:has() | selector(:has(*)) |
| Nesting | selector(&) |
@layer | at-rule(@layer) |
| Anchor positioning | (anchor-name: --a) |
interpolate-size | (interpolate-size: allow-keywords) |
field-sizing | (field-sizing: content) |
| Customizable select | (appearance: base-select) |
| Scroll-state queries | (container-type: scroll-state) |
| View transitions | (view-transition-name: a) |
oklch() | (color: oklch(0 0 0)) |
light-dark() | (color: light-dark(#000, #fff)) |
| Subgrid | (grid-template-rows: subgrid) |
@starting-style | _no direct test; wrap in @supports block_ |
---
Common Patterns
Entry/exit animation (display: none toggling)
.dialog {
opacity: 1;
scale: 1;
transition: opacity 0.3s, scale 0.3s, display 0.3s allow-discrete, overlay 0.3s allow-discrete;
@starting-style {
opacity: 0;
scale: 0.95;
}
}
.dialog:not([open]) {
opacity: 0;
scale: 0.95;
display: none;
}Scroll progress bar
.progress-bar {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: oklch(0.6 0.2 260);
transform-origin: inline-start;
animation: progress-grow linear;
animation-timeline: scroll(root);
}
@keyframes progress-grow { from { scale: 0 1; } to { scale: 1 1; } }Anchor-positioned tooltip
.trigger { anchor-name: --trigger; }
.tooltip {
position: fixed;
position-anchor: --trigger;
inset-area: block-start;
margin-block-end: 0.5rem;
position-try-fallbacks: flip-block, flip-inline;
}Dark mode with light-dark()
:root {
color-scheme: light dark;
--surface: light-dark(oklch(0.98 0 0), oklch(0.15 0 0));
--text: light-dark(oklch(0.2 0 0), oklch(0.9 0 0));
--border: light-dark(oklch(0.85 0 0), oklch(0.3 0 0));
}Container query responsive card
.card-wrapper { container-type: inline-size; container-name: card; }
@container card (inline-size < 300px) {
.card { flex-direction: column; }
.card img { aspect-ratio: 16/9; inline-size: 100%; }
}
@container card (inline-size >= 300px) {
.card { flex-direction: row; }
.card img { inline-size: 150px; }
}Typed custom property with @property
@property --hue {
syntax: "<number>";
initial-value: 260;
inherits: true;
}
/* Now --hue can be animated/transitioned */
.el { --hue: 260; transition: --hue 0.3s; }
.el:hover { --hue: 145; }
.el { color: oklch(0.6 0.2 var(--hue)); }---
Progressive Enhancement Template
/* Base: works everywhere */
.layout {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
/* Enhancement: container queries */
@supports (container-type: inline-size) {
.wrapper { container-type: inline-size; }
@container (inline-size >= 600px) {
.layout { display: grid; grid-template-columns: 1fr 1fr; }
}
}
/* Enhancement: :has() */
@supports selector(:has(*)) {
.card:has(img) { grid-template-rows: 200px 1fr; }
}
/* Enhancement: anchor positioning */
@supports (anchor-name: --a) {
.trigger { anchor-name: --trigger; }
.tooltip {
position: fixed;
position-anchor: --trigger;
inset-area: block-start;
}
}
/* Enhancement: Chrome-only features */
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
}
@supports (appearance: base-select) {
select { appearance: base-select; }
}
@supports (field-sizing: content) {
textarea { field-sizing: content; }
}---
Anti-Patterns Recap
| # | Anti-Pattern | Fix |
|---|---|---|
| 1 | !important for overrides | @layer cascade control |
| 2 | Deep nesting .a .b .c .d | Flat selectors, @scope |
| 3 | IDs for styling #header | Classes .header |
| 4 | @media for component sizing | Container queries |
| 5 | JS scroll listeners | Scroll-driven animations |
| 6 | JS tooltip positioning | Anchor positioning |
| 7 | max-height: 9999px | interpolate-size: allow-keywords |
| 8 | margin-left / padding-right | Logical properties (margin-inline-start) |
| 9 | rgba() with commas | rgb(r g b / a) space-separated |
| 10 | text-wrap: balance on paragraphs | Only headings / short text (perf) |
Color — Building Perceptually Uniform Color Systems
Sources:
- CSS Color Level 4 — W3C Specification
- CSS Color Level 5 — color-mix(), relative color syntax- OKLCH in CSS: why we moved from RGB and HSL — Evil Martians
- MDN: CSS Color — Mozilla
---
Why Modern Color Spaces Matter
sRGB represents roughly 35% of the colors the human eye can see. Every hex value, rgb(), and hsl() color is confined to this gamut. Modern displays (P3 on Apple devices, Rec. 2020 on HDR) can render far more — Display-P3 covers ~50% more colors than sRGB.
The bigger problem is perceptual uniformity. HSL claims to separate lightness from hue, but it lies: hsl(60, 100%, 50%) (yellow) appears far brighter than hsl(240, 100%, 50%) (blue) despite identical L values. This makes programmatic palette generation unreliable. OKLCH fixes this — equal lightness values produce equal perceived brightness across all hues.
flowchart TB
subgraph Gamut["Color Gamut Size"]
direction TB
SRGB["sRGB<br>~35% of visible spectrum<br>hex, rgb(), hsl()"]
P3["Display-P3<br>~50% larger than sRGB<br>color(display-p3 ...)"]
LAB["Lab / LCH<br>Device-independent<br>Approximates human vision"]
end
SRGB -->|"subset of"| P3
P3 -->|"subset of"| LAB
subgraph Working["Working Spaces (use these)"]
OKLCH["OKLCH<br>Perceptually uniform<br>Polar: L, C, H<br>RECOMMENDED"]
OKLAB["OKLab<br>Perceptually uniform<br>Cartesian: L, a, b<br>Best for gradients"]
end
LAB -.->|"corrected version"| OKLCH
LAB -.->|"corrected version"| OKLAB
style OKLCH fill:#16a34a,color:#fff,stroke:#15803d
style OKLAB fill:#2563eb,color:#fff,stroke:#1d4ed8
style SRGB fill:#94a3b8,color:#1e293b,stroke:#64748b
style P3 fill:#f59e0b,color:#1e293b,stroke:#d97706
style LAB fill:#8b5cf6,color:#fff,stroke:#7c3aedKey takeaway: Use OKLCH as your default color space. Fall back to sRGB only for legacy compatibility. Use color-mix() in oklch for blending. Use OKLab for gradient interpolation.
---
Modern Syntax: Space-Separated Values
All modern color functions use space-separated syntax with / for alpha. The comma-separated form and the a suffix (rgba, hsla) are legacy.
/* ❌ Legacy syntax — do not use in new code */
.legacy {
color: rgba(31, 41, 59, 0.26);
background: hsla(220, 14%, 96%, 0.5);
}
/* ✅ Modern syntax — space-separated, slash for alpha */
.modern {
color: rgb(31 41 59 / 0.26);
background: hsl(220 14% 96% / 0.5);
border-color: oklch(0.5 0.15 240 / 0.8);
}Consistent across rgb(), hsl(), oklch(), oklab(), lab(), lch(), and color(). Always use it.
---
OKLCH — The Recommended Color Space
Use OKLCH as the default for all new projects, design systems, and palette generation.
Parameters
| Parameter | Range | Description |
|---|---|---|
| L (Lightness) | 0 to 1 | 0 = black, 1 = white. Perceptually linear. |
| C (Chroma) | 0 to ~0.4 | Color intensity. 0 = gray. Most usable values 0.01-0.3. |
| H (Hue) | 0 to 360 | 0=pink, 30=red, 70=orange, 90=yellow, 145=green, 240=blue, 300=purple. |
When to Use
- Design systems — Palette scales where each step has equal visual weight
- Programmatic palette generation — Sweep hue at constant L and C for consistent vibrancy
- Accessible color pairs — Predictable contrast ratios because L is perceptually linear
- Any new project — There is no reason to start with HSL anymore
OKLCH in Practice
:root {
/* Constant lightness + chroma, varying hue = equally vibrant colors */
--color-blue: oklch(0.6 0.2 240);
--color-green: oklch(0.6 0.2 145);
--color-red: oklch(0.6 0.2 30);
--color-purple: oklch(0.6 0.2 300);
/* Lightness scale — same hue/chroma, varying lightness */
--blue-100: oklch(0.93 0.04 240);
--blue-300: oklch(0.75 0.13 240);
--blue-500: oklch(0.55 0.22 240);
--blue-700: oklch(0.35 0.17 240);
--blue-900: oklch(0.20 0.08 240);
}Why Not HSL
/* ❌ HSL: Same L=50%, wildly different perceived brightness */
.hsl-problem {
--yellow: hsl(60, 100%, 50%); /* Appears very bright */
--blue: hsl(240, 100%, 50%); /* Appears very dark */
}
/* ✅ OKLCH: Same L=0.7, same perceived brightness */
.oklch-solution {
--yellow: oklch(0.7 0.15 90); /* Looks equally bright */
--blue: oklch(0.7 0.15 240); /* Looks equally bright */
}---
OKLab — Best for Gradient Interpolation
OKLab uses Cartesian coordinates (L, a, b) instead of polar (L, C, H). Use OKLab when interpolating between colors — it avoids the "muddy middle" and hue banding that sRGB produces, and avoids the hue-angle ambiguity of polar spaces.
/* ❌ sRGB interpolation — muddy gray in the middle */
.gradient-bad {
background: linear-gradient(in srgb, oklch(0.7 0.25 145), oklch(0.7 0.25 30));
}
/* ✅ OKLab interpolation — vibrant, no dead zone */
.gradient-good {
background: linear-gradient(in oklab, oklch(0.7 0.25 145), oklch(0.7 0.25 30));
}
/* Also good: OKLCH with explicit hue direction */
.gradient-oklch {
background: linear-gradient(in oklch shorter hue, oklch(0.7 0.25 145), oklch(0.7 0.25 30));
}Use in oklab on gradients by default. Use in oklch shorter hue when you need to control hue direction (e.g., rainbow effects with longer hue).
---
Display-P3 — Wide-Gamut Colors
Display-P3 covers vivid reds, greens, and oranges that sRGB cannot represent. Always declare an sRGB fallback first — browsers that do not support color() ignore it.
.vivid-button {
/* sRGB fallback */
background-color: oklch(0.65 0.25 145);
/* P3 override — only applied on wide-gamut displays */
background-color: color(display-p3 0.2 0.8 0.3);
}Detecting P3 Support
@media (color-gamut: p3) {
:root {
--brand-green: color(display-p3 0.2 0.85 0.3);
--brand-red: color(display-p3 0.95 0.2 0.15);
}
}
@supports (color: color(display-p3 1 1 1)) {
.accent { color: color(display-p3 0.9 0.3 0.2); }
}OKLCH Often Replaces P3
OKLCH with high chroma values exceeds sRGB — browsers automatically map to the widest available gamut. OKLCH with high chroma is often simpler than explicit P3 declarations.
/* Equivalent on a P3 display: */
.option-a { color: color(display-p3 0.2 0.8 0.3); }
.option-b { color: oklch(0.72 0.3 145); } /* Auto-mapped to P3 */Prefer OKLCH unless you need exact Display-P3 coordinates from a design tool.
---
color-mix() — Blending Colors
color-mix() blends two colors in a specified color space. The interpolation space matters enormously.
CRITICAL: Interpolation Space Matters
/* ❌ Mixing in sRGB — produces muddy, desaturated result */
.muddy { background: color-mix(in srgb, blue, yellow); }
/* ✅ Mixing in OKLCH — preserves vibrancy */
.vibrant { background: color-mix(in oklch, blue, yellow); }Always use in oklch unless you have a specific reason for another space.
Common Patterns
:root {
--brand: oklch(0.55 0.22 240);
/* Tinting — mix with white */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
--brand-lighter: color-mix(in oklch, var(--brand) 15%, white);
/* Shading — mix with black */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
--brand-darker: color-mix(in oklch, var(--brand) 50%, black);
/* Semi-transparent — mix with transparent */
--brand-hover: color-mix(in oklch, var(--brand) 80%, transparent);
--brand-ghost: color-mix(in oklch, var(--brand) 10%, transparent);
/* Muted — mix with same-lightness gray */
--brand-muted: color-mix(in oklch, var(--brand) 60%, oklch(0.55 0 0));
/* Blend two theme colors */
--secondary: oklch(0.6 0.2 145);
--accent: color-mix(in oklch, var(--brand), var(--secondary));
}---
Relative Color Syntax — Deriving Colors from Tokens
Relative color syntax creates new colors by transforming an existing color's channels. This replaces Sass darken(), lighten(), adjust-hue(), and similar functions.
Syntax
oklch(from var(--base) calc(l - 0.1) c h)Channel names (l, c, h for OKLCH) become variables usable in calc().
Transformations
:root {
--base: oklch(0.6 0.2 240);
/* Darken / Lighten */
--darker: oklch(from var(--base) calc(l - 0.15) c h);
--lighter: oklch(from var(--base) calc(l + 0.15) c h);
/* Desaturate / Saturate */
--muted: oklch(from var(--base) l calc(c - 0.1) h);
--vivid: oklch(from var(--base) l calc(c + 0.1) h);
--gray: oklch(from var(--base) l 0 h);
/* Complement (opposite hue) */
--complement: oklch(from var(--base) l c calc(h + 180));
/* Analogous (adjacent hues) */
--analog-left: oklch(from var(--base) l c calc(h - 30));
--analog-right: oklch(from var(--base) l c calc(h + 30));
/* Add alpha */
--semi: oklch(from var(--base) l c h / 0.5);
--ghost: oklch(from var(--base) l c h / 0.1);
}Cross-Space Conversion
Input any format, transform in OKLCH. The browser converts automatically:
:root {
--legacy-brand: #1e40af;
--brand-dark: oklch(from var(--legacy-brand) calc(l - 0.1) c h);
}Complete Token System
Derive an entire palette from a single base value:
:root {
--brand: oklch(0.55 0.22 240);
/* Auto-generated scale */
--brand-50: oklch(from var(--brand) 0.97 calc(c * 0.1) h);
--brand-100: oklch(from var(--brand) 0.93 calc(c * 0.2) h);
--brand-200: oklch(from var(--brand) 0.85 calc(c * 0.4) h);
--brand-300: oklch(from var(--brand) 0.75 calc(c * 0.6) h);
--brand-400: oklch(from var(--brand) 0.65 calc(c * 0.8) h);
--brand-500: oklch(from var(--brand) l c h);
--brand-600: oklch(from var(--brand) calc(l - 0.08) c h);
--brand-700: oklch(from var(--brand) calc(l - 0.16) c h);
--brand-800: oklch(from var(--brand) calc(l - 0.24) calc(c * 0.8) h);
--brand-900: oklch(from var(--brand) calc(l - 0.32) calc(c * 0.6) h);
/* Semantic tokens from the same base */
--brand-hover: oklch(from var(--brand) calc(l - 0.05) c h);
--brand-active: oklch(from var(--brand) calc(l - 0.1) c h);
--brand-disabled: oklch(from var(--brand) l calc(c * 0.3) h / 0.5);
--brand-ring: oklch(from var(--brand) l c h / 0.3);
--brand-surface: oklch(from var(--brand) 0.97 calc(c * 0.1) h);
}---
light-dark() and color-scheme — Theming
light-dark() returns one of two colors depending on the active color scheme. It requires color-scheme to be set.
Setup (REQUIRED)
:root {
color-scheme: light dark; /* Without this, light-dark() always returns the light value */
}Basic Usage
:root {
color-scheme: light dark;
--text: light-dark(oklch(0.2 0 0), oklch(0.9 0 0));
--surface: light-dark(oklch(0.99 0 0), oklch(0.15 0 0));
--border: light-dark(oklch(0.85 0 0), oklch(0.3 0 0));
--brand: light-dark(oklch(0.5 0.2 240), oklch(0.7 0.2 240));
}color-scheme vs prefers-color-scheme
| Feature | color-scheme | prefers-color-scheme |
|---|---|---|
| What | CSS property on elements | Media query |
| Scope | Per-element (inherited) | Whole page (OS-level) |
| Controls | UA defaults + light-dark() | Conditional @media blocks |
light-dark() reads the computed color-scheme, not the media query. If the OS is in dark mode but color-scheme: light is forced on an element, light-dark() returns the light value inside that element.
Per-Component Theme Overrides
color-scheme is inherited but can be overridden per element — enabling "island" theming:
/* Force sidebar to always be dark, regardless of OS setting */
.sidebar {
color-scheme: dark;
background: var(--surface); /* Resolves to dark variant */
color: var(--text); /* Resolves to dark variant */
}
/* Force modal to always be light */
.modal {
color-scheme: light;
background: var(--surface); /* Resolves to light variant */
}Complete Theming Pattern
:root {
color-scheme: light dark;
--surface-0: light-dark(oklch(1 0 0), oklch(0.13 0 0));
--surface-1: light-dark(oklch(0.97 0 0), oklch(0.18 0 0));
--surface-2: light-dark(oklch(0.94 0 0), oklch(0.23 0 0));
--text-primary: light-dark(oklch(0.15 0 0), oklch(0.93 0 0));
--text-secondary: light-dark(oklch(0.4 0 0), oklch(0.7 0 0));
--text-muted: light-dark(oklch(0.6 0 0), oklch(0.5 0 0));
--brand: light-dark(oklch(0.5 0.2 240), oklch(0.7 0.18 240));
--border: light-dark(oklch(0.87 0 0), oklch(0.3 0 0));
--shadow: light-dark(oklch(0 0 0 / 0.1), oklch(0 0 0 / 0.4));
}
/* Scheme-specific assets still need the media query */
@media (prefers-color-scheme: dark) {
.logo { content: url('/logo-dark.svg'); }
}---
Putting It All Together
Combines OKLCH, relative color syntax, color-mix(), and light-dark():
:root {
color-scheme: light dark;
/* Primitive hues — single source of truth */
--hue-primary: 240;
--hue-success: 145;
--hue-danger: 25;
/* Base colors */
--primary: oklch(0.55 0.22 var(--hue-primary));
--success: oklch(0.6 0.2 var(--hue-success));
--danger: oklch(0.6 0.22 var(--hue-danger));
/* Semantic tokens */
--bg: light-dark(oklch(0.99 0 0), oklch(0.13 0 0));
--text: light-dark(oklch(0.15 0 0), oklch(0.93 0 0));
/* Interactive states via relative color */
--primary-hover: oklch(from var(--primary) calc(l - 0.05) c h);
--primary-active: oklch(from var(--primary) calc(l - 0.1) c h);
--primary-ring: oklch(from var(--primary) l c h / 0.3);
/* Surface tints via color-mix() */
--primary-surface: color-mix(in oklch, var(--primary) 8%, var(--bg));
--danger-surface: color-mix(in oklch, var(--danger) 8%, var(--bg));
}
.btn-primary {
background: var(--primary);
color: oklch(from var(--primary) 0.98 0 h);
&:hover { background: var(--primary-hover); }
&:active { background: var(--primary-active); }
&:focus-visible { outline: 2px solid var(--primary-ring); }
}
.alert-danger {
background: var(--danger-surface);
border-left: 3px solid var(--danger);
color: oklch(from var(--danger) calc(l - 0.15) c h);
}---
Anti-Patterns
Hardcoded Palettes
/* ❌ Manually defining every shade — unmaintainable */
:root {
--blue-100: #dbeafe;
--blue-300: #93c5fd;
--blue-500: #3b82f6;
--blue-700: #1d4ed8;
--blue-900: #1e3a8a;
}
/* ✅ Derive from a single base */
:root {
--blue: oklch(0.6 0.22 240);
--blue-100: oklch(from var(--blue) 0.93 calc(c * 0.2) h);
--blue-500: oklch(from var(--blue) l c h);
--blue-900: oklch(from var(--blue) 0.2 calc(c * 0.6) h);
}Legacy rgba() / hsla() Syntax
/* ❌ Comma-separated, function name with 'a' suffix */
.legacy { background: rgba(59, 130, 246, 0.5); }
/* ✅ Space-separated, slash for alpha */
.modern { background: oklch(0.6 0.22 240 / 0.5); }Missing color-scheme Declaration
/* ❌ light-dark() always returns the first (light) value */
:root { --bg: light-dark(white, #111); }
/* ✅ Must declare color-scheme first */
:root {
color-scheme: light dark;
--bg: light-dark(white, oklch(0.13 0 0));
}Mixing in sRGB
/* ❌ sRGB interpolation produces muddy, desaturated mixes */
.bad { color: color-mix(in srgb, red, blue); }
/* ✅ OKLCH preserves chroma */
.good { color: color-mix(in oklch, red, blue); }Using HSL for Programmatic Palettes
/* ❌ HSL lightness is not perceptually uniform */
.palette {
--step-1: hsl(220, 80%, 90%);
--step-2: hsl(220, 80%, 70%);
--step-3: hsl(220, 80%, 50%); /* perceived jump is uneven */
}
/* ✅ OKLCH lightness is perceptually uniform */
.palette {
--step-1: oklch(0.9 0.08 240);
--step-2: oklch(0.7 0.16 240);
--step-3: oklch(0.5 0.22 240); /* equal perceptual steps */
}For non-Baseline features, always feature-detect with @supports or use progressive enhancement. Check MDN or Baseline for current browser support.Components — Interactive UI Without JavaScript
Sources: Open UI, Popover API, CSS Anchor Positioning, Invoker Commands, Interop 2025. These APIs eliminate entire categories of JavaScript — tooltips, dropdowns, modals, popovers, auto-sizing inputs — with declarative HTML attributes and CSS properties.
---
Customizable <select> — appearance: base-select
Before this, styling a dropdown with images or icons meant rebuilding the widget in JavaScript — destroying native accessibility, keyboard navigation, and form integration. Opt in with CSS; unsupported browsers render a standard <select> as fallback.
Opt-In and Pseudo-Elements
select,
::picker(select) {
appearance: base-select;
}| Pseudo-Element | Targets | Purpose |
|---|---|---|
::picker(select) | Dropdown popover on open | Style dropdown container |
::picker-icon | Arrow/chevron indicator | Replace or animate the arrow |
option::checkmark | Selected-item indicator | Style or hide the checkmark |
<selectedcontent> | Reflected chosen option | Display rich content in collapsed state |
Use when options need rich content — flags, avatars, color swatches. Do NOT use for plain text lists.
Complete Example
<select>
<option value="us">
<img src="flags/us.svg" alt="" width="20" height="15"> United States
</option>
<option value="gb">
<img src="flags/gb.svg" alt="" width="20" height="15"> United Kingdom
</option>
<selectedcontent></selectedcontent>
</select>/* ❌ JavaScript approach — custom dropdown widget */// 200+ lines: keyboard handling, ARIA roles, click-outside,
// scroll locking, focus trapping...
class CustomSelect extends HTMLElement { /* ... */ }/* ✅ Pure CSS — native <select> with full styling */
select,
::picker(select) {
appearance: base-select;
}
select {
font: inherit;
border: 1px solid oklch(0.75 0 0);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
min-inline-size: 200px;
background: oklch(1 0 0);
cursor: pointer;
}
select::picker(select) {
background: oklch(0.99 0 0);
border: 1px solid oklch(0.8 0 0);
border-radius: 0.75rem;
padding: 0.25rem;
box-shadow: 0 8px 24px oklch(0 0 0 / 0.12);
}
select::picker-icon {
transition: rotate 0.2s ease;
}
select:open::picker-icon {
rotate: 180deg;
}
option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
}
option:hover {
background: oklch(0.95 0.02 260);
}
option::checkmark {
color: oklch(0.55 0.2 145);
}
selectedcontent {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}Unsupported browsers ignore base-select and show a standard <select>. Feature-detect with @supports (appearance: base-select).
---
Popover API
Replaces custom JavaScript tooltip/dropdown/notification implementations. Popovers get automatic top-layer promotion, focus management, and light dismiss.
Three Types
| Type | Attribute | Light Dismiss | Closes Others | Use When |
|---|---|---|---|---|
auto | popover or popover="auto" | Yes (click outside, ESC) | Yes | Menus, dropdowns, action sheets |
manual | popover="manual" | No | No | Notifications, toasts, persistent panels |
hint | popover="hint" | Yes | Only other hints | Tooltips, ephemeral help text |
Basic Usage
<button popovertarget="my-menu">Open Menu</button>
<div id="my-menu" popover>
<p>Menu content here</p>
</div>Styling
/* ❌ JavaScript approach — manually toggling visibility */const btn = document.querySelector('#toggle');
const menu = document.querySelector('#menu');
btn.addEventListener('click', () => menu.classList.toggle('open'));
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && e.target !== btn)
menu.classList.remove('open');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') menu.classList.remove('open');
});/* ✅ Pure CSS — popover with enter/exit transitions */
[popover] {
opacity: 0;
scale: 0.95;
transition: opacity 0.2s, scale 0.2s,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
}
[popover]:popover-open {
opacity: 1;
scale: 1;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
scale: 0.95;
}
}
[popover]::backdrop {
background: oklch(0 0 0 / 0.25);
backdrop-filter: blur(2px);
}hint Type — Ephemeral Tooltips
Unlike auto, opening a hint does not close other open auto popovers. A user can have a dropdown open and still see a tooltip on a menu item.
<button popovertarget="settings-menu">Settings</button>
<div id="settings-menu" popover>
<button popovertarget="tip-1">Dark Mode</button>
<div id="tip-1" popover="hint">Switches to dark theme</div>
</div>---
Invoker Commands — Declarative Button Actions
Replace JavaScript event listeners with commandfor and command attributes.
Built-In Commands
| Command | Action | Target |
|---|---|---|
toggle-popover | Toggle popover open/closed | [popover] |
show-popover | Show popover | [popover] |
hide-popover | Hide popover | [popover] |
show-modal | Open dialog as modal | <dialog> |
close | Close dialog | <dialog> |
<!-- ❌ JavaScript approach -->
<button id="open-btn">Open Dialog</button>
<dialog id="my-dialog">
<p>Dialog content</p>
<button id="close-btn">Close</button>
</dialog>
<script>
document.getElementById('open-btn').addEventListener('click', () => {
document.getElementById('my-dialog').showModal();
});
document.getElementById('close-btn').addEventListener('click', () => {
document.getElementById('my-dialog').close();
});
</script><!-- ✅ Invoker commands — no JS -->
<button commandfor="my-dialog" command="show-modal">Open Dialog</button>
<dialog id="my-dialog">
<p>Dialog content</p>
<button commandfor="my-dialog" command="close">Close</button>
</dialog>Custom Commands
Prefix with -- for application-specific actions. Custom commands fire a command event on the target.
<button commandfor="player" command="--play">Play</button>
<button commandfor="player" command="--pause">Pause</button>
<div id="player">...</div>
<script>
document.getElementById('player').addEventListener('command', (e) => {
if (e.command === '--play') video.play();
if (e.command === '--pause') video.pause();
});
</script>Use invokers whenever a button's sole purpose is to trigger an action on another element.
---
Dialog Light Dismiss — closedby Attribute
Controls how a <dialog> can be dismissed — click-outside-to-close previously required JavaScript.
| Value | ESC Key | Click Outside | Programmatic .close() |
|---|---|---|---|
none | No | No | Yes |
closerequest (modal default) | Yes | No | Yes |
any | Yes | Yes | Yes |
<!-- ❌ JavaScript approach for click-outside-to-close -->
<dialog id="dlg"><p>Content</p></dialog>
<script>
const dlg = document.getElementById('dlg');
dlg.addEventListener('click', (e) => {
const rect = dlg.getBoundingClientRect();
if (e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom)
dlg.close();
});
</script><!-- ✅ Declarative — closedby="any" handles ESC + click outside -->
<dialog closedby="any" id="dlg">
<p>Content</p>
<button commandfor="dlg" command="close">Close</button>
</dialog>
<button commandfor="dlg" command="show-modal">Open</button>dialog {
border: none;
border-radius: 1rem;
padding: 2rem;
max-inline-size: min(90vw, 500px);
box-shadow: 0 12px 40px oklch(0 0 0 / 0.2);
}
dialog::backdrop {
background: oklch(0 0 0 / 0.4);
backdrop-filter: blur(4px);
}---
Interest Invokers — Hover/Focus-Triggered UI
The interestfor attribute triggers popovers on hover or focus rather than click. Default delay: 0.5s show/hide. Works on buttons and links.
<button interestfor="tooltip-1">Hover me</button>
<div id="tooltip-1" popover="hint">This appears on hover/focus</div>/* ❌ JavaScript approach — hover intent detection */let timer;
trigger.addEventListener('mouseenter', () => {
timer = setTimeout(() => tooltip.showPopover(), 300);
});
trigger.addEventListener('mouseleave', () => {
clearTimeout(timer);
setTimeout(() => tooltip.hidePopover(), 200);
});/* ✅ CSS interest-delay — declarative timing */
[interestfor] {
interest-delay: 300ms;
}Replaces mouseenter/mouseleave/focusin/focusout JavaScript. Keyboard focus triggers the popover automatically.
---
Anchor Positioning
Replaces Floating UI, Popper.js, and Tether. An element declares itself as an anchor; another positions relative to it. The browser handles viewport collision, auto-flipping, and scroll-aware repositioning. Part of Interop 2025, Baseline.
Core Properties
| Property | Purpose | Example |
|---|---|---|
anchor-name | Declare an anchor | anchor-name: --trigger |
position-anchor | Connect to an anchor | position-anchor: --trigger |
position-area | Place relative to anchor | position-area: block-end |
position-try-fallbacks | Fallbacks on overflow | position-try-fallbacks: flip-block |
Basic Positioning
/* ❌ JavaScript approach — Floating UI / Popper.js */import { computePosition, flip, offset } from '@floating-ui/dom';
computePosition(trigger, tooltip, {
placement: 'bottom',
middleware: [offset(8), flip()],
}).then(({ x, y }) => {
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
});/* ✅ Pure CSS — anchor positioning */
.trigger {
anchor-name: --trigger;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
position-area: block-end;
margin-block-start: 8px;
}Auto-Flip on Overflow
.tooltip {
position: fixed;
position-anchor: --trigger;
position-area: block-end;
position-try-fallbacks: flip-block, flip-inline;
}| Keyword | Behavior |
|---|---|
flip-block | Flip bottom to top (or top to bottom) |
flip-inline | Flip right to left (or left to right) |
flip-block flip-inline | Flip both axes |
Custom Fallback Positions
.dropdown-menu {
position: fixed;
position-anchor: --menu-trigger;
position-area: block-end span-inline-end;
position-try-fallbacks: --above, --left;
}
@position-try --above {
position-area: block-start span-inline-end;
}
@position-try --left {
position-area: inline-start;
}Anchored Container Queries — Arrow Direction
When a tooltip flips, the arrow must point the other way. Detect which fallback was applied:
.tooltip-wrapper {
container-type: anchored;
}
.tooltip-arrow {
rotate: 0deg; /* default: arrow up, tooltip below */
}
@container anchored(fallback: flip-block) {
.tooltip-arrow {
rotate: 180deg; /* flipped: arrow down, tooltip above */
}
}Complete Tooltip — Zero JavaScript
Combines anchor positioning, popover hint, and interest invokers into a fully declarative tooltip:
<button interestfor="tip" style="anchor-name: --btn">Hover me</button>
<div id="tip" popover="hint" style="position-anchor: --btn">
Tooltip content
</div>[popover="hint"] {
position: fixed;
position-area: block-start;
margin-block-end: 6px;
position-try-fallbacks: flip-block;
background: oklch(0.2 0 0);
color: oklch(0.95 0 0);
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
border: none;
opacity: 0;
transition: opacity 0.15s;
}
[popover="hint"]:popover-open {
opacity: 1;
}
@starting-style {
[popover="hint"]:popover-open {
opacity: 0;
}
}---
field-sizing: content — Auto-Sizing Form Fields
Auto-resizes inputs, textareas, and selects to fit content.
/* ❌ JavaScript approach — auto-expanding textarea */const textarea = document.querySelector('textarea');
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
// Bugs: flickers on fast typing, breaks on box-sizing changes/* ✅ Pure CSS — auto-sizing */
textarea {
field-sizing: content;
}Constraints and Progressive Enhancement
Always set bounds — without them, the element grows infinitely. Works on input[type="text"], textarea, and select.
textarea {
min-block-size: 100px;
resize: vertical;
}
@supports (field-sizing: content) {
textarea {
field-sizing: content;
min-block-size: 3lh;
max-block-size: 50vh;
resize: none;
}
}---
Decision Guide
| Need | Solution | JS Required |
|---|---|---|
| Styled dropdown with images | appearance: base-select | No |
| Tooltip on hover | interestfor + popover="hint" + anchor positioning | No |
| Dropdown menu on click | popovertarget + popover + anchor positioning | No |
| Modal (ESC + click outside) | <dialog closedby="any"> + command="show-modal" | No |
| Toast / notification | popover="manual" + command="show-popover" | Minimal (timer) |
| Auto-expanding textarea | field-sizing: content | No |
| Positioned element that flips | position-try-fallbacks: flip-block | No |
| Button opens/closes a panel | commandfor + command="toggle-popover" | No |
For non-Baseline features, always feature-detect with @supports or use progressive enhancement. Check MDN or Baseline for current browser support.Layout — Choosing the Right Layout System
Sources:
- CSS Grid Layout Level 2 (Subgrid) — W3C
- CSS Grid Layout Level 3 (Grid Lanes) — W3C Editor's Draft
- CSS Containment Level 3 (Container Queries) — W3C
- CSS Box Sizing Level 4 (Intrinsic Sizing) — W3C
- MDN: CSS Grid — Mozilla
- MDN: Container Queries — Mozilla
Modern CSS provides four layout primitives: Grid for 2D structure, Flexbox for 1D alignment, Container Queries for component-level responsiveness, and intrinsic sizing for content-driven dimensions. Every layout decision starts by identifying which axis matters, whether the component owns its sizing or its container does, and whether children need cross-alignment.
---
Layout Decision Flowchart
flowchart TD
START["What are you laying out?"] --> DIM{"How many axes<br>matter?"}
DIM -->|"Both rows + columns"| GRID["Use CSS Grid"]
DIM -->|"Single axis only"| FLEX["Use Flexbox"]
GRID --> CHILD{"Do child internals need<br>to align across siblings?"}
CHILD -->|"Yes — card headers,<br>footers must line up"| SUBGRID["Grid + Subgrid"]
CHILD -->|"No"| GRIDTYPE{"Fixed row heights<br>or variable?"}
GRIDTYPE -->|"Uniform rows"| STDGRID["Standard Grid<br>repeat(auto-fill, minmax())"]
GRIDTYPE -->|"Variable heights,<br>waterfall style"| LANES{"Browser support<br>acceptable?"}
LANES -->|"Yes (Safari TP,<br>Firefox flag)"| GRIDLANES["display: grid-lanes"]
LANES -->|"No — need production"| FALLBACK["Grid + JS library<br>or CSS columns fallback"]
FLEX --> FLEXDIR{"Wrapping needed?"}
FLEXDIR -->|"No — single line"| FLEXLINE["Flexbox<br>(nav, toolbar, centering)"]
FLEXDIR -->|"Yes — wraps to<br>multiple lines"| CONSIDER["Consider Grid instead<br>(better 2D control)"]
style GRID fill:#3b82f6,stroke:#2563eb,color:white
style SUBGRID fill:#6366f1,stroke:#4f46e5,color:white
style STDGRID fill:#3b82f6,stroke:#2563eb,color:white
style GRIDLANES fill:#f59e0b,stroke:#d97706,color:white
style FLEX fill:#10b981,stroke:#059669,color:white
style FLEXLINE fill:#10b981,stroke:#059669,color:white
style CONSIDER fill:#ef4444,stroke:#dc2626,color:white
style FALLBACK fill:#6b7280,stroke:#4b5563,color:white---
1. CSS Grid
Use Grid when layout involves both rows and columns — page structure, card grids, dashboard panels, form layouts. Grid is the default choice for any 2D arrangement.
When to use: Page-level structure, card grids, spanning items across rows/columns, responsive grids that reflow without media queries. When NOT to use: Single-axis alignment (use Flexbox), content that flows like text (use normal flow).
Responsive Grid with auto-fill / auto-fit
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}auto-fill keeps empty tracks (items stay at minmax size). auto-fit collapses empty tracks (items stretch to fill). Use auto-fill for consistent widths; use auto-fit when fewer items should expand.
Named Grid Areas
.page {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 260px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
}
.page-header { grid-area: header; }
.page-sidebar { grid-area: sidebar; }
.page-main { grid-area: main; }
.page-footer { grid-area: footer; }
@media (width < 768px) {
.page {
grid-template-areas: "header" "main" "sidebar" "footer";
grid-template-columns: 1fr;
}
}Named Lines
Semantic anchors for item placement — no counting track numbers.
.layout {
display: grid;
grid-template-columns:
[full-start] 1fr
[content-start] minmax(0, 960px)
[content-end] 1fr
[full-end];
}
.layout > * { grid-column: content; }
.layout > .full-bleed { grid-column: full; }Grid Alignment
.grid-container {
place-items: center; /* align all items: block + inline */
place-content: space-between; /* distribute tracks within container */
}
.grid-item {
place-self: center end; /* override for a single item */
}---
2. Subgrid
Use Subgrid when child elements inside grid items must align across sibling items. The canonical example: a row of cards where every heading, body, and footer lines up, regardless of content length.
Browser support: Baseline (Sep 2023). 97% global coverage. Production-ready.
/* ❌ Without subgrid: each card's rows are independent */
.card {
display: grid;
grid-template-rows: auto 1fr auto; /* isolated — won't align across cards */
}/* ✅ With subgrid: card rows inherit parent grid tracks */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-auto-rows: auto;
gap: 1rem 1.5rem;
}
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
}
.card-heading { grid-row: 1; }
.card-body { grid-row: 2; }
.card-footer { grid-row: 3; }Subgrid can apply to one axis independently. Use grid-template-rows: subgrid with custom columns, or grid-template-columns: subgrid with custom rows.
Fallback for Legacy Browsers
.card {
display: grid;
grid-template-rows: auto 1fr auto;
}
@supports (grid-template-rows: subgrid) {
.card {
grid-row: span 3;
grid-template-rows: subgrid;
}
}---
3. Grid Lanes (Masonry)
Use Grid Lanes when content has variable heights and must pack tightly — image galleries, Pinterest-style feeds, mixed-content cards.
Status: Experimental. Behind flags in Safari TP, Firefox, and Chrome. Not production-ready.
Grid Lanes defines strict lanes (columns) via grid-template-columns but lets items flow freely in the stacking axis. Items pack into whichever lane gets them closest to the top.
.gallery {
display: grid-lanes;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}The flow-tolerance property relaxes strict shortest-lane placement — items can go into a slightly taller lane to stay closer to source order:
.gallery {
display: grid-lanes;
grid-template-columns: repeat(3, 1fr);
flow-tolerance: 50px;
gap: 1rem;
}Progressive Enhancement
/* ❌ No fallback — broken in most browsers */
.gallery { display: grid-lanes; }/* ✅ Progressive enhancement */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
@supports (display: grid-lanes) {
.gallery { display: grid-lanes; }
}CSS columns as an alternative fallback (closer visual approximation):
.gallery {
columns: 250px;
column-gap: 1rem;
}
.gallery > * { break-inside: avoid; margin-bottom: 1rem; }
@supports (display: grid-lanes) {
.gallery {
columns: unset;
display: grid-lanes;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.gallery > * { break-inside: unset; margin-bottom: unset; }
}| Feature | Standard Grid | Grid Lanes | Flexbox Wrap |
|---|---|---|---|
| Axis control | 2D (rows + columns) | Lanes + free stacking | 1D + wrap |
| Variable heights | Gaps between items | Tight packing | Uneven rows |
| Named areas/lines | Yes | Yes (lane axis only) | No |
| Browser support | Baseline | Experimental | Baseline |
---
4. Flexbox
Use Flexbox for single-axis layout — distributing space, centering, navigation bars, toolbars, inline controls.
When to use: Centering, nav bars, toolbars, button groups, distributing space in a row/column. When NOT to use: 2D layouts (use Grid), wrapping card grids (use Grid with auto-fill), layouts where items in different rows must align vertically.
Centering
.center {
display: flex;
place-content: center;
place-items: center;
}Space Distribution
.toolbar {
display: flex;
gap: 0.5rem;
}
.toolbar .push-right { margin-inline-start: auto; }
.nav { display: flex; justify-content: space-between; }
.tabs { display: flex; justify-content: space-evenly; }Flex Sizing
/* Equal columns */
.equal { display: flex; & > * { flex: 1; } }
/* Fixed sidebar + fluid main */
.layout {
display: flex;
.sidebar { flex: 0 0 260px; }
.main { flex: 1; }
}Anti-Pattern: Flexbox for Card Grids
/* ❌ Last row items stretch unevenly */
.card-grid { display: flex; flex-wrap: wrap; gap: 1rem; }
.card { flex: 1 1 300px; }/* ✅ Grid guarantees consistent columns */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}---
5. Container Queries
Container queries shift responsive design from viewport-based to component-based. The core principle: components should respond to their container, not the viewport. A card in a wide main column should look different from the same card in a narrow sidebar — and that logic belongs to the card, not a media query that knows the page layout.
Container vs Media Query Mental Model
flowchart LR
subgraph MEDIA["@media — viewport-driven"]
direction TB
VP["Viewport Width"] --> MQ["@media (width >= 768px)"]
MQ --> PAGE["Page-level layout changes"]
PAGE --> NOTE1["Components coupled<br>to page structure"]
end
subgraph CONTAINER["@container — component-driven"]
direction TB
CT["Container Width"] --> CQ["@container (inline-size >= 400px)"]
CQ --> COMP["Component adapts<br>to its own space"]
COMP --> NOTE2["Components are<br>portable and reusable"]
end
MEDIA -.->|"Shift from viewport<br>thinking to container<br>thinking"| CONTAINER
style MEDIA fill:#ef4444,stroke:#dc2626,color:white
style CONTAINER fill:#10b981,stroke:#059669,color:whiteUse @media for page-level layout (sidebar collapses, nav changes). Use @container for component-level adaptation.
Setting Up Containment
.card-wrapper {
container: card / inline-size; /* name / type shorthand */
}Container types: inline-size (query width — use almost always), size (query both axes — needs defined height), normal (default, cannot query).
Size Queries
.card { display: grid; gap: 1rem; }
@container card (inline-size >= 400px) {
.card { grid-template-columns: 200px 1fr; }
}
@container card (inline-size >= 700px) {
.card { grid-template-columns: 300px 1fr 200px; }
.card-metadata { display: block; }
}Use range syntax. Avoid legacy min-width/max-width.
/* ❌ Legacy */ @container card (min-width: 400px) { }
/* ✅ Modern */ @container card (inline-size >= 400px) { }
/* ✅ Range */ @container card (400px <= inline-size <= 800px) { }Style Queries
Respond to custom property values on the container — conditional styling based on context, not size.
@container card style(--variant: featured) {
.card { border: 2px solid oklch(0.7 0.15 250); }
}
@container card style(--variant: compact) {
.card { padding: 0.5rem; font-size: 0.875rem; }
}Range syntax for style queries: compare numeric custom property values.
@container card style(--priority >= 3) {
.card { border-inline-start: 4px solid oklch(0.6 0.2 30); }
}Browser support: Feature-detect with @supports. Not yet cross-browser.
Container Query Units
| Unit | Relative To |
|---|---|
cqw | 1% of container width |
cqh | 1% of container height (needs container-type: size) |
cqi | 1% of container inline size (prefer over cqw) |
cqb | 1% of container block size (needs container-type: size) |
cqmin | Smaller of cqi or cqb |
cqmax | Larger of cqi or cqb |
Prefer cqi over cqw — it respects writing direction.
.card-wrapper { container-type: inline-size; }
.card-title { font-size: clamp(1rem, 3cqi, 1.75rem); }
.card-body { padding: clamp(0.75rem, 2cqi, 2rem); }When to Use Each
| Concern | @container | @media |
|---|---|---|
| Component adapts to available space | Yes | No |
| Page structure changes | No | Yes |
| Component reused in multiple contexts | Yes | No |
| User preferences (dark mode, motion) | No | Yes (prefers-*) |
Practical Example: Reusable Article Card
.article-container { container: article / inline-size; }
.article-card {
display: grid;
gap: 0.75rem;
padding: clamp(0.75rem, 2cqi, 1.5rem);
}
.article-card .thumbnail { aspect-ratio: 16 / 9; object-fit: cover; }
@container article (inline-size < 400px) {
.article-card { grid-template-columns: 1fr; }
.article-card .metadata { display: none; }
}
@container article (400px <= inline-size < 700px) {
.article-card { grid-template-columns: 160px 1fr; }
}
@container article (inline-size >= 700px) {
.article-card { grid-template-columns: 240px 1fr auto; }
}This card works in a full-width column, a 300px sidebar, a modal, or a dashboard widget without changing any CSS.
---
6. Intrinsic Sizing
Intrinsic sizing keywords let elements size themselves based on content or container, replacing fixed widths.
| Keyword | Behavior | Use When |
|---|---|---|
min-content | Shrinks to narrowest without overflow (longest word) | Collapsible sidebars, tight table columns |
max-content | Expands to fit all content, no wrapping | Tags, badges, inline labels |
fit-content | Grows with content up to available space, then wraps | Dialogs, tooltips, captions |
stretch | Fills available space (margin box). Replaces -webkit-fill-available | Full-width buttons, full-height apps |
.dialog { width: fit-content; max-width: 90vw; min-width: 320px; }
.tag { width: max-content; padding-inline: 0.75em; }
.app { min-height: stretch; }In Grid Tracks
.layout {
display: grid;
grid-template-columns:
min-content /* sidebar: as narrow as content allows */
1fr /* main: remaining space */
max-content; /* aside: as wide as content needs */
}fit-content() Function
Differs from the fit-content keyword — accepts a maximum size argument:
.page {
display: grid;
grid-template-columns: fit-content(200px) 1fr fit-content(300px);
}Extrinsic vs Intrinsic
/* ❌ Fragile fixed widths */
.sidebar { width: 250px; }
.dialog { width: 500px; }/* ✅ Content-driven sizing */
.sidebar { width: fit-content; min-width: 200px; max-width: 350px; }
.dialog { width: fit-content; max-width: min(600px, 90vw); }---
7. Aspect Ratio
The aspect-ratio property declares a preferred ratio, replacing the padding-top percentage hack.
.video { aspect-ratio: 16 / 9; width: 100%; }
.avatar { aspect-ratio: 1; width: 4rem; border-radius: 50%; }
.card { aspect-ratio: 3 / 4; }Legacy vs Modern
/* ❌ The padding-top hack */
.video-wrapper {
position: relative; padding-top: 56.25%; height: 0;
}
.video-wrapper iframe {
position: absolute; inset: 0; width: 100%; height: 100%;
}/* ✅ Declarative aspect ratio */
.video-wrapper { aspect-ratio: 16 / 9; width: 100%; }
.video-wrapper iframe { width: 100%; height: 100%; }Combining with object-fit
.thumbnail { aspect-ratio: 4 / 3; object-fit: cover; object-position: center; }
.product-image { aspect-ratio: 1; object-fit: contain; background: oklch(0.97 0 0); }object-fit | Behavior |
|---|---|
cover | Fills box, crops overflow. Hero images, thumbnails. |
contain | Fits inside box, may letterbox. Product images, logos. |
fill | Stretches to fill (distorts). Rarely useful. |
none | Natural size, no scaling. Crops if larger than box. |
scale-down | Like contain, never scales up. |
Preventing Layout Shift
Combine auto with a fallback ratio for images not yet loaded:
img {
aspect-ratio: auto 4 / 3;
/* Natural ratio once loaded; 4/3 before load to prevent CLS */
}For non-Baseline features, always feature-detect with @supports or use progressive enhancement. Check MDN or Baseline for current browser support.---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
flex-wrap for card grids | Last row stretches unevenly | display: grid with auto-fill |
@media for component layout | Couples component to viewport | @container queries |
| Padding-top hack for ratio | Fragile, extra wrapper | aspect-ratio property |
| Fixed widths on fluid elements | Breaks on resize | Intrinsic sizing or minmax() |
| Nested grids without subgrid | Child rows misalign | grid-template-rows: subgrid |
| JS masonry libraries | Heavy, layout thrashing | display: grid-lanes (when ready) |
height: 100vh on mobile | Ignores mobile browser chrome | 100dvh |
float for layout | Legacy, fragile | Grid or Flexbox |
Performance — Rendering, Typography, and Accessibility
Sources: CSS Containment Level 2, CSS Logical Properties Level 1, CSS Text Level 4, MDN Web Docs: CSS. All features below are Baseline unless noted otherwise.
---
content-visibility: auto — Skip Off-Screen Rendering
The single most impactful CSS performance property. When applied to off-screen sections, the browser skips layout, paint, and style computation entirely. Chrome's own documentation reports up to 7x rendering improvement on long pages. The browser lazily renders content only when it approaches the viewport — CSS-level virtualization without JavaScript.
Use on discrete page sections that start off-screen: article cards below the fold, long lists, tab panels, accordion bodies, comment threads, footer regions.
CRITICAL: Never apply to above-the-fold content. The browser delays rendering of content-visibility: auto elements, which directly delays Largest Contentful Paint (LCP). If your hero section, primary heading, or first visible card has this property, you have made performance worse.
Do not apply to tiny elements. The overhead of containment tracking per element exceeds the rendering savings for small items. Apply to section-level containers, not to each individual child.
Always measure before and after. Use Chrome DevTools Performance panel or Lighthouse. If you cannot measure a difference, remove it.
Required: contain-intrinsic-size
When the browser skips rendering, it does not know the element's height. Without a size hint, the scrollbar jumps as elements render. You must pair with contain-intrinsic-size. The auto keyword tells the browser to remember the actual size after first render.
/* ❌ Missing size hint — scrollbar jumps, layout shifts */
.article-section {
content-visibility: auto;
}
/* ✅ Proper implementation with size estimate */
.article-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}Pattern: Long Article Page
/* Above the fold — NO content-visibility */
.hero,
.article-intro {
/* Renders immediately */
}
/* Below-fold sections skip rendering until needed */
.article-body,
.comments-section,
.related-articles,
.site-footer {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}Baseline. Feature-detect with @supports (content-visibility: auto).
---
CSS Containment — The contain Property
contain tells the browser that an element's internals are independent from the rest of the page. The rendering engine can skip recalculating layout, paint, or style for the entire document when something inside that element changes. content-visibility: auto implicitly applies containment — contain is the manual, granular version.
| Value | What It Isolates | Effect |
|---|---|---|
size | Element's size from children | Element does not resize based on content. You must set explicit dimensions. |
layout | Internal layout from external | Floats, counters, layout changes inside do not affect outside. |
paint | Painting boundary | Content that overflows is clipped. No visible overflow. |
style | Counter and content scoping | CSS counters and quotes do not leak out. |
strict | All of the above | size layout paint style. Maximum isolation. |
content | Layout + paint + style | Like strict but without size — element can still auto-size. |
Use contain: content on independent UI components — cards, widgets, modals, sidebar panels — especially inside container queries. Use contain: strict when you know exact dimensions (ad slots, map containers, iframes).
Do not use `contain: size` without explicit dimensions — the element collapses to zero. Do not use `contain: paint` on elements that intentionally overflow — tooltips and dropdowns will be clipped.
/* ❌ Size containment without dimensions — collapses to 0x0 */
.widget {
contain: strict;
}
/* ✅ Size containment with explicit dimensions */
.widget {
contain: strict;
inline-size: 300px;
block-size: 200px;
}
/* ✅ Content containment — auto-sizes but isolates layout/paint */
.card {
contain: content;
}Container queries require containment. When you declare container-type: inline-size, the browser implicitly applies contain: inline-size layout style. No need for explicit contain on container query containers.
---
will-change — Compositor Hints
will-change tells the browser which properties will animate, allowing it to create a GPU layer in advance. It is an optimization hint, not a directive. Ideally, add it dynamically just before animation starts, then remove it after — GPU layers consume memory for the entire time they exist.
Do not apply to everything. Every will-change creates a GPU compositor layer consuming video memory. A page with * { will-change: transform; } can cause the browser to fall back to software rendering.
Do not use on elements that never animate. It wastes GPU memory with zero benefit.
Do not use as a "performance sprinkle." If animation is janky, profile first. The cause is usually layout thrashing, not missing compositor hints.
/* ❌ Wasteful — GPU layers for everything */
* {
will-change: transform, opacity;
}
/* ✅ Targeted — only the property that animates */
.modal-overlay {
will-change: opacity;
transition: opacity 0.3s ease;
}
/* ✅ Dynamic via CSS — prime layer on parent hover */
.card-list:hover .card {
will-change: transform;
}
.card:hover {
transform: translateY(-4px);
}---
Typography
text-wrap: balance — Equalized Line Lengths
Distributes text across lines so each line is approximately the same width. Eliminates the problem of headings with one or two orphaned words on the last line.
Use only on headings and short text. Browsers cap the balancing algorithm at 6 lines (Chrome) to 10 lines (spec). Beyond that, balancing is silently ignored. The algorithm is computationally expensive — it tries multiple line-breaking solutions.
/* ❌ Applying to all text — performance-heavy, ignored on long content */
body {
text-wrap: balance;
}
/* ✅ Headings only */
:is(h1, h2, h3, h4, h5, h6) {
text-wrap: balance;
}text-wrap: pretty — Better Paragraph Rag
Adjusts line breaks to avoid orphans and improves the right-edge "rag" of left-aligned text. Designed for paragraphs and long-form content. Progressive enhancement — Firefox does not support it yet. Degrades gracefully.
/* Headings balanced, body text pretty */
:is(h1, h2, h3, h4, h5, h6) {
text-wrap: balance;
}
:is(p, li, dd, blockquote, figcaption) {
text-wrap: pretty;
}Fluid Typography with clamp()
Creates font sizes that scale smoothly between a minimum and maximum, eliminating breakpoint-based jumps. Always use rem for min/max to respect user font-size preferences.
/* ❌ Breakpoint-based — jumpy */
h1 { font-size: 1.5rem; }
@media (min-width: 768px) { h1 { font-size: 2rem; } }
@media (min-width: 1200px) { h1 { font-size: 3rem; } }
/* ✅ Fluid — smooth scaling */
h1 {
font-size: clamp(1.5rem, 1rem + 2vw, 3rem);
}Common Fluid Scale
:root {
--text-sm: clamp(0.875rem, 0.8rem + 0.3vw, 1rem);
--text-base: clamp(1rem, 0.875rem + 0.5vw, 1.25rem);
--text-2xl: clamp(1.25rem, 1rem + 1vw, 1.75rem);
--text-3xl: clamp(1.5rem, 1rem + 2vw, 2.5rem);
--text-4xl: clamp(2rem, 1.2rem + 3.2vw, 3.5rem);
}
body { font-size: var(--text-base); }
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }text-box — Optical Vertical Centering
text-box (shorthand for text-box-trim and text-box-edge) trims extra space above and below text from the font's line-height metrics. This invisible space causes text to appear off-center in buttons, badges, and tight containers.
/* ❌ Padding hacks to compensate for font metrics */
.badge {
padding: 0.15em 0.5em 0.25em;
}
/* ✅ Trim to cap height and alphabetic baseline */
.badge {
text-box: trim-both cap alphabetic;
padding: 0.25em 0.5em;
}| Value | Trims |
|---|---|
trim-both | Above and below |
trim-start / trim-end | Above only / below only |
cap alphabetic | To cap height and alphabetic baseline |
ex alphabetic | To x-height and alphabetic baseline |
Use on buttons, badges, pills, tags. Do not apply globally — it changes effective line-height and breaks paragraph spacing.
Line-Height Units: lh and rlh
lh equals the computed line-height of the current element. rlh equals the root element's line-height. These keep spacing proportional to the text rhythm.
p {
margin-block-end: 1lh; /* Exactly one line of text */
}
.section-divider {
block-size: 3rlh; /* Three root line-heights */
}
.drop-cap::first-letter {
font-size: 3lh;
float: inline-start;
line-height: 1;
}---
Logical Properties — Writing-Direction-Aware CSS
Logical properties replace physical direction properties (left, right, top, bottom) with flow-relative equivalents. In LTR, margin-inline-start maps to margin-left. In RTL, it maps to margin-right. In vertical writing modes, the mapping rotates accordingly.
The Rule
Always use logical properties in new code. No downside — identical behavior in LTR horizontal text, automatic RTL and vertical writing mode support. Physical properties are legacy.
Full Mapping Table
| Physical | Logical |
|---|---|
margin-top / margin-bottom | margin-block-start / margin-block-end |
margin-left / margin-right | margin-inline-start / margin-inline-end |
margin-top + bottom / left + right | margin-block / margin-inline |
padding-top / padding-bottom | padding-block-start / padding-block-end |
padding-left / padding-right | padding-inline-start / padding-inline-end |
padding-top + bottom / left + right | padding-block / padding-inline |
border-top / border-bottom | border-block-start / border-block-end |
border-left / border-right | border-inline-start / border-inline-end |
border-top-left-radius | border-start-start-radius |
border-top-right-radius | border-start-end-radius |
border-bottom-left-radius | border-end-start-radius |
border-bottom-right-radius | border-end-end-radius |
width / height | inline-size / block-size |
min-width / min-height | min-inline-size / min-block-size |
max-width / max-height | max-inline-size / max-block-size |
top / bottom | inset-block-start / inset-block-end |
left / right | inset-inline-start / inset-inline-end |
top + bottom / left + right | inset-block / inset-inline |
text-align: left / right | text-align: start / end |
float: left / right | float: inline-start / inline-end |
clear: left / right | clear: inline-start / inline-end |
overflow-x / overflow-y | overflow-inline / overflow-block |
resize: horizontal / vertical | resize: inline / block |
Before/After
/* ❌ Physical — breaks in RTL */
.sidebar {
margin-left: 2rem;
padding-right: 1rem;
border-bottom: 1px solid oklch(0.85 0 0);
width: 300px;
top: 0;
left: 0;
text-align: left;
}
/* ✅ Logical — works in LTR, RTL, and vertical */
.sidebar {
margin-inline-start: 2rem;
padding-inline-end: 1rem;
border-block-end: 1px solid oklch(0.85 0 0);
inline-size: 300px;
inset-block-start: 0;
inset-inline-start: 0;
text-align: start;
}---
Accessibility Media Queries
These media queries detect user preferences at the OS level. Respecting them is not optional — it is a core accessibility requirement.
prefers-reduced-motion: reduce
The user has requested reduced motion. Affects users with vestibular disorders, motion sensitivity, or those who find animation distracting.
Universal Reset Pattern
Apply once, globally. Removes all animations and transitions unless explicitly overridden for essential motion (loading spinners, progress bars).
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Why 0.01ms instead of 0s? Setting 0s prevents animationend and transitionend events from firing, breaking JavaScript listeners. Near-zero fires the events while being visually instant.
/* Selective override for essential animation */
@media (prefers-reduced-motion: reduce) {
.loading-spinner {
animation-duration: 1.5s !important;
animation-iteration-count: infinite !important;
}
}prefers-contrast: more / less
The user wants higher or lower contrast. more is the common case.
@media (prefers-contrast: more) {
:root {
--border-color: oklch(0 0 0);
--text-secondary: oklch(0.25 0 0);
}
button, input, select, textarea {
border: 2px solid var(--border-color);
}
:focus-visible {
outline-width: 3px;
}
}forced-colors: active — Windows High Contrast Mode
The OS overrides all colors with a user-defined palette. Most CSS colors are ignored. Use system color keywords to work with the forced palette, not against it.
| System Color | Maps To |
|---|---|
CanvasText / Canvas | Text / background |
LinkText | Link color |
ButtonText / ButtonFace | Button text / background |
Highlight / HighlightText | Selection background / text |
@media (forced-colors: active) {
.btn {
border: 2px solid ButtonText;
background: ButtonFace;
color: ButtonText;
}
.icon-status {
forced-color-adjust: none;
border: 2px solid currentColor;
}
:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}prefers-color-scheme: light / dark
Detects OS-level color scheme preference. Always pair with the color-scheme property — without it, browser chrome (scrollbars, form controls) stays light even when your page is dark.
/* ❌ No color-scheme — browser UI stays light */
@media (prefers-color-scheme: dark) {
body {
background: oklch(0.15 0 0);
color: oklch(0.9 0 0);
}
}
/* ✅ Full dark adaptation */
:root {
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: oklch(0.15 0 0);
--text: oklch(0.9 0 0);
}
}Use light-dark() for inline values without repeating media queries:
:root {
color-scheme: light dark;
--surface: light-dark(oklch(0.98 0 0), oklch(0.15 0 0));
--text: light-dark(oklch(0.15 0 0), oklch(0.9 0 0));
--border: light-dark(oklch(0.85 0 0), oklch(0.3 0 0));
}---
Progressive Enhancement Meta-Pattern
Build the baseline first, then enhance with @supports. Users on older browsers get a functional page, not a broken one.
Template
/* Baseline — works everywhere */
.component {
/* Functional styles */
}
/* Enhancement — better where supported */
@supports (feature: value) {
.component {
/* Modern feature */
}
}Practical Examples
/* Content visibility — baseline renders normally, enhancement skips off-screen */
.article-section {
margin-block-end: 2rem;
}
@supports (content-visibility: auto) {
.article-section:not(:first-child) {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
}
/* Text wrapping — baseline wraps normally, enhancements improve typography */
@supports (text-wrap: balance) {
h2 { text-wrap: balance; }
}
@supports (text-wrap: pretty) {
p { text-wrap: pretty; }
}
/* Layered enhancements — each independent */
.card {
padding: 1rem;
border: 1px solid oklch(0.85 0 0);
}
@supports (container-type: inline-size) {
.card-wrapper { container-type: inline-size; }
@container (inline-size > 400px) {
.card { padding: 2rem; }
}
}
@supports (text-box: trim-both cap alphabetic) {
.card-title { text-box: trim-both cap alphabetic; }
}---
Modern Viewport Units
The traditional vh is ambiguous on mobile — the viewport height changes when the address bar shows or hides, causing 100vh to overflow the visible area. Modern units resolve this.
| Unit | Name | Meaning |
|---|---|---|
svh | Small viewport height | Address bar visible (smallest viewport) |
lvh | Large viewport height | Address bar hidden (largest viewport) |
dvh | Dynamic viewport height | Tracks current height as it changes |
svw / lvw / dvw | Width variants | Rarely differ from vw |
dvmin / dvmax | Dynamic min/max | Smaller/larger of dvw and dvh |
Use dvh for elements that must fill the visible viewport exactly. Use svh for elements that must never overflow. Use lvh when you want maximum space and accept brief overflow during address bar transitions.
Do not use `dvh` with transitions on height — dvh changes continuously as the address bar animates, causing jank. Do not replace all `vh` with `dvh` blindly — on desktop they are identical.
/* ❌ Legacy — overflows on mobile */
.hero {
height: 100vh;
}
/* ✅ Dynamic — fills visible viewport */
.hero {
height: 100dvh;
}
/* ✅ Safe minimum — never overflows */
.full-screen-modal {
min-height: 100svh;
}Pattern: Mobile App Shell
.app-shell {
display: grid;
grid-template-rows: auto 1fr auto;
block-size: 100dvh;
}
.app-header {
position: sticky;
inset-block-start: 0;
}
.app-content {
overflow-y: auto;
}Fallback
.hero {
height: 100vh; /* Fallback */
height: 100dvh; /* Override in supporting browsers */
}---
Performance Checklist
Rendering
- [ ] Apply
content-visibility: autoto below-fold sections withcontain-intrinsic-size - [ ] Verify
content-visibilityis NOT on above-fold content (check LCP) - [ ] Use
contain: contenton independent component containers - [ ] Apply
will-changeonly to actively animating elements
Typography
- [ ] Use
text-wrap: balanceon headings, not paragraphs - [ ] Use
text-wrap: prettyon paragraphs as progressive enhancement - [ ] Use
clamp()withremmin/max for fluid typography - [ ] Use
text-box: trim-both cap alphabeticfor optical centering in buttons/badges
Logical Properties
- [ ] Use logical properties in all new code
- [ ] Use
inset-block/inset-inlinefor positioned elements - [ ] Use
text-align: start/endinstead ofleft/right
Accessibility
- [ ] Include
prefers-reduced-motion: reduceuniversal reset - [ ] Adapt UI for
prefers-contrast: more - [ ] Test with
forced-colors: active - [ ] Set
color-scheme: light darkon:root - [ ] Use
light-dark()for scheme-adaptive colors
Viewport
- [ ] Use
dvhfor mobile full-viewport layouts instead ofvh - [ ] Use
svhfor fixed layouts that must never overflow - [ ] Provide
vhfallback beforedvhfor legacy browsers