*/
```
---
## Inline conditional styles without JavaScript
URL: https://modern-css.com/inline-conditional-styles-without-javascript/
Category: Workflow
Difficulty: Intermediate
Baseline: limited
Browser support: 35%
Conditional styling used to require JavaScript to toggle classes based on state. CSS if() lets you write inline conditions that respond to custom property values, media features, and container queries.
### How it works
Component variants have always required separate CSS rules: .btn.primary, .btn.danger, .btn.outlined, each repeating the same properties with different values. JavaScript often manages which classes are applied, adding another layer of complexity.
CSS if() lets you write conditional logic inline. A single .btn rule can test style(--variant: primary) and choose values accordingly. It can also test media features like prefers-color-scheme: dark directly inside a property value, eliminating the need for separate @media blocks for simple value swaps.
### Why use this
- Inline conditions: Write if/else logic directly in property values. No separate rule blocks for each variant.
- Style queries: Test custom property values with style(). Respond to --variant, --size, --state without JavaScript class management.
- Media-aware: Can also test media features like prefers-color-scheme or prefers-reduced-motion right inside a property value.
### Modern CSS
```css
.btn {
background: if(
style(--variant: primary): blue;
else: gray
);
}
```
---
## Reusable CSS logic without Sass mixins
URL: https://modern-css.com/reusable-css-logic-without-sass-mixins/
Category: Workflow
Difficulty: Intermediate
Baseline: limited
Browser support: 67%
Reusable calculations and logic in stylesheets used to require Sass or other preprocessors. Native CSS @function lets you define custom functions that return computed values, right in your stylesheet.
### How it works
Preprocessors like Sass introduced functions decades ago, allowing developers to encapsulate reusable calculations. But Sass functions compile to static values at build time — they can't react to runtime conditions like viewport size changes or user preferences.
CSS @function brings this capability natively to the browser. You define a function with @function --name(--param) { @return ... } and call it anywhere a value is expected: font-size: --fluid(1rem, 2rem). Because it runs at runtime, it can use viewport units, custom properties, and other dynamic values that Sass simply can't access.
### Why use this
- No build step: Native CSS functions run in the browser. No Sass compiler, no PostCSS plugin, no build pipeline required.
- Runtime computed: Unlike Sass which compiles to static values, CSS @function can use runtime values like viewport units, custom properties, and env().
- Composable: Functions can call other functions and use custom properties. Build complex design token systems with pure CSS.
### Modern CSS
```css
@function --fluid(--min, --max) {
@return clamp(
var(--min),
50vi,
var(--max)
);
}
h1 {
font-size: --fluid(1.5rem, 3rem);
}
```
---
## Corner shapes beyond rounded borders
URL: https://modern-css.com/corner-shapes-beyond-rounded-borders/
Category: Layout
Difficulty: Beginner
Baseline: limited
Browser support: 67%
Non-circular corner shapes like squircles, scoops, and notches used to require clip-path polygon hacks or SVG masks. Now corner-shape provides named corner styles natively.
### How it works
Apple popularized the superellipse (squircle) for app icons, and developers have been trying to replicate it in CSS ever since. The border-radius property only creates circular arcs, so achieving smoother, more continuous curves required clip-path with dozens of polygon points or external SVG masks.
The corner-shape property changes how border-radius draws its curves. Values include squircle (superellipse), scoop (concave curve), notch (straight cut), and bevel (angled cut). It works with existing border-radius values — just add one line to transform any rounded corner into a different shape.
### Why use this
- One property: Replace complex polygon hacks or SVG masks with a single corner-shape declaration. Squircle, scoop, notch, bevel — all built in.
- Works with border-radius: corner-shape modifies how border-radius curves are drawn. The radius controls the size, the shape controls the curvature.
- iOS-style squircles natively: The superellipse (squircle) shape used by Apple's design system is now a CSS one-liner. No more approximations.
### Modern CSS
```css
.card {
border-radius: 2em;
corner-shape: squircle;
}
```
---
## Responsive clip paths without SVG
URL: https://modern-css.com/responsive-clip-paths-without-svg/
Category: Animation
Difficulty: Advanced
Baseline: limited
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/basic-shape/shape
Complex clip paths used to require SVG path() definitions with fixed coordinates that don't scale responsively. The CSS shape() function uses responsive units like percentages and viewport units for truly flexible shapes.
### How it works
The SVG path() function brought complex shapes to CSS clip-path, but it inherited SVG's fixed coordinate system. A path defined with M0 200 L100 0 L200 200 only works for a 200×200 element. Resize it, and the clipping breaks.
The CSS shape() function solves this by using standard CSS coordinates and units. You write from 0% 100%, line to 50% 0%, line to 100% 100% — readable, responsive, and animatable. It supports lines, curves (quadratic and cubic), arcs, and smooth joins, all with CSS units that scale with the element.
### Why use this
- Responsive by default: Use percentages, em, rem, vw — any CSS unit. The shape scales with the element, unlike fixed-coordinate SVG paths.
- CSS-native syntax: No SVG path mini-language to learn. Uses familiar CSS commands: line, curve, arc, smooth, with readable coordinate pairs.
- Animatable: Unlike path(), shape() can be animated and transitioned between different shapes smoothly with CSS animations.
### Modern CSS
```css
.shape {
clip-path: shape(
from 0% 100%,
line to 50% 0%,
line to 100% 100%
);
}
```
---
## Scroll spy without IntersectionObserver
URL: https://modern-css.com/scroll-spy-without-intersection-observer/
Category: Selector
Difficulty: Intermediate
Baseline: limited
Browser support: 48%
Highlighting navigation links based on scroll position used to require JavaScript IntersectionObserver or scroll event listeners. Now CSS can track which section is in view with scroll-target-group and the :target-current pseudo-class.
### How it works
Scroll spy navigation — highlighting the current section's link as the user scrolls — has always required JavaScript. The typical approach uses IntersectionObserver to watch each section, then toggles an 'active' class on the corresponding nav link. This works but requires careful threshold tuning, cleanup on unmount, and can fall out of sync with fast scrolling.
The CSS approach uses :target-current, a pseudo-class that matches anchor links pointing to the element currently in the scroll port. Combined with smooth scrolling and scroll-snap, you get a complete scroll-spy navigation with no JavaScript at all. The browser handles the tracking natively, so it's always perfectly in sync.
### Why use this
- Zero JavaScript: No IntersectionObserver, no scroll event listeners, no class toggling. The browser tracks which section is in view natively.
- Always in sync: The :target-current pseudo-class updates in real-time as the user scrolls. No timing bugs or threshold tuning.
- Works with CSS scroll-snap: Pairs naturally with scroll-snap for section-based layouts. The active indicator follows snap points automatically.
### Modern CSS
```css
.scroller {
overflow-y: auto;
}
nav a:target-current {
color: var(--accent);
}
```
---
## Filling available space without calc workarounds
URL: https://modern-css.com/filling-available-space-without-calc-workarounds/
Category: Layout
Difficulty: Beginner
Baseline: limited
Browser support: 90%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/stretch
Making an element fill its container while keeping margins meant calc(100% - left - right) or risking overflow with width: 100%. The stretch keyword fills available space while respecting margins automatically.
### How it works
When you set width: 100% on an element with margins, the element overflows its container because 100% refers to the content box of the parent, and margins are added on top. The workaround was width: calc(100% - 40px) where you hard-code the exact margin values. Change the margins and you must update the calc too.
The stretch keyword resolves to the available space in the containing block, applying the result to the element's margin box instead of the box determined by box-sizing. This means the element fills its container exactly, with margins intact, without any manual math. It works on width, height, min-width, max-height, and all sizing properties.
### Why use this
- Margin-aware: stretch applies to the margin box. Margins are respected without manual subtraction.
- No overflow: Unlike width: 100%, stretch can never overflow the container when margins or padding are present.
- Works on height too: Use height: stretch to fill the block axis. Great for full-height layouts without 100vh quirks.
### Modern CSS
```css
.full {
width: stretch;
}
```
---
## Staggered animations without nth-child hacks
URL: https://modern-css.com/staggered-animations-without-nth-child-hacks/
Category: Animation
Difficulty: Intermediate
Baseline: limited
Browser support: 70%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/sibling-index
Staggered list animations used to require manually setting --index on each :nth-child or counting in JavaScript. The sibling-index() function gives every element automatic awareness of its position.
### How it works
The classic staggered animation technique required setting a custom property like --i on every :nth-child selector, then using calc(0.1s * var(--i)) for the delay. This was fragile: add a list item and you need another rule. Some developers set the index via inline styles or JavaScript instead, adding complexity.
The sibling-index() function returns a 1-based integer representing the element's position among its siblings. Use it directly in calc() for staggered delays, radial positioning, or any layout that depends on element order. Its companion sibling-count() returns the total number of siblings, enabling formulas like distributing items evenly around a circle.
### Why use this
- Automatic indexing: sibling-index() returns each element's 1-based position. No manual counting, no matter how many items.
- Dynamic safe: Add or remove items and the stagger adapts automatically. The old nth-child approach breaks when the list changes.
- Count siblings too: sibling-count() gives the total. Use both for radial layouts, equal distribution, and more.
### Modern CSS
```css
li {
transition: opacity .25s ease, translate .25s ease;
transition-delay:
calc(0.1s * (sibling-index() - 1));
}
```
---
## Carousel navigation without a JavaScript library
URL: https://modern-css.com/carousel-navigation-without-a-javascript-library/
Category: Layout
Difficulty: Advanced
Baseline: limited
Browser support: 72%
Carousels used to need libraries like Swiper.js or Slick for navigation buttons and dot indicators. CSS scroll-button and scroll-marker pseudo-elements give you native, accessible carousel UI.
### How it works
Building a carousel traditionally meant a JavaScript library: Swiper.js, Slick, or Flickity. These libraries create navigation buttons, dot indicators, handle scroll snap, and manage active states. That's a lot of JavaScript and custom CSS for what is fundamentally a scrolling UI.
CSS now provides two pseudo-elements for scroll containers. ::scroll-button(direction) creates prev/next buttons that scroll by ~85% of the container's visible area and auto-disable at the ends. ::scroll-marker on each item creates dot indicators grouped in a ::scroll-marker-group. Use the :target-current pseudo-class to style the active dot. Both are fully stylable with CSS.
### Why use this
- Native performance: Scroll buttons and markers are browser-generated pseudo-elements. No JS, no DOM manipulation, no resize observers.
- Accessible by default: Buttons are focusable and auto-disable at scroll ends. Markers work as anchor links. Keyboard and screen reader friendly.
- Drop the library: Swiper.js is ~40 KB. The CSS approach is zero bytes of JavaScript and fully stylable.
### Modern CSS
```css
.carousel::scroll-button(left) {
content: "⬅" / "Scroll left";
}
.carousel::scroll-button(right) {
content: "➡" / "Scroll right";
}
.carousel { scroll-marker-group: after; }
.carousel li::scroll-marker {
content: '';
width: 10px; height: 10px;
border-radius: 50%;
}
```
---
## Vertical text centering without padding hacks
URL: https://modern-css.com/vertical-text-centering-without-padding-hacks/
Category: Typography
Difficulty: Beginner
Baseline: limited
Browser support: 79%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/text-box-trim
Text always looked optically off-center because font metrics include extra space for ascenders and descenders. The text-box property trims that invisible space for true visual centering.
### How it works
Every font has internal metrics: ascent (space above for accents) and descent (space below for descenders like g and y). When you center text in a button or container, the browser centers the full content box including these invisible metrics. The visual center of the actual letters ends up slightly too low.
The text-box shorthand combines text-box-trim and text-box-edge. Using trim-both trims above and below, while cap alphabetic sets the trim edges to the cap height (top of capital letters) and alphabetic baseline. The result: the visible text is truly centered within its container, regardless of font or size.
### Why use this
- True centering: Trims invisible font metric space above cap height and below alphabetic baseline. Math and optics finally agree.
- Works everywhere: Buttons, badges, tags, headings, pills. Anywhere text looked subtly off-center now looks perfect.
- Font-agnostic: The browser reads the font's actual metrics. Switching fonts doesn't break the centering.
### Modern CSS
```css
.btn {
padding: 10px 20px;
text-box: trim-both cap alphabetic;
}
```
---
## Hover tooltips without JavaScript events
URL: https://modern-css.com/hover-tooltips-without-javascript-events/
Category: Layout
Difficulty: Intermediate
Baseline: limited
Browser support: 86%
MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover
Tooltips required JavaScript mouseenter/mouseleave listeners, focus handling, and manual positioning. Now popover=hint with the interestfor attribute gives you declarative, accessible hover UI.
### How it works
Building accessible tooltips required at least four event listeners (mouseenter, mouseleave, focus, blur), a delay mechanism to prevent flicker, position logic, and careful DOM management. Libraries like Tippy.js exist specifically because this is so tedious to get right.
The new popover=hint type paired with the interestfor attribute handles all of this declaratively. The interestfor attribute on the trigger element points to the tooltip's ID. The browser shows the popover when the user 'shows interest' (hover, focus, long-press) and hides it when interest ends. Customize the delay with the interest-delay CSS property. Unlike popover=auto, hint popovers coexist with other open popovers.
### Why use this
- All input modes: The browser triggers on hover (mouse), focus (keyboard), and long-press (touch). You write zero event handling.
- Non-destructive: Hint popovers don't close other open auto or manual popovers. Layered UI coexists naturally.
- Built-in delay: The interest-delay property (default 0.5s) prevents accidental triggers. Configurable with CSS.
### Modern CSS
```css
Hover me
Tooltip content
```
---
## Modal controls without onclick handlers
URL: https://modern-css.com/modal-controls-without-onclick-handlers/
Category: HTML
Difficulty: Beginner
Baseline: limited
Browser support: 72%
MDN: https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement/command
Opening a dialog modally required onclick handlers calling showModal(). Invoker Commands let buttons perform actions on other elements declaratively with commandfor and command attributes.
### How it works
To open a
modally, you needed JavaScript: either an inline onclick that calls showModal(), or an addEventListener in a script. Same for popovers and closing. Every interactive trigger required its own JS wiring.
Invoker Commands (Chrome 135+) add two HTML attributes: commandfor takes the ID of the target element, and command specifies the action. Built-in commands mirror their JS counterparts: show-modal, close, show-popover, hide-popover, toggle-popover. Custom commands prefixed with -- fire a command event on the target for extensibility.
### Why use this
- Declarative: Link a button to its target with commandfor (like the for attribute on labels). No querySelector, no addEventListener.
- Multiple commands: show-modal, close, show-popover, hide-popover, toggle-popover. One pattern for all interactive elements.
- Custom commands too: Prefix with -- for custom commands handled by the command event. Extensible without frameworks.
### Modern CSS
```css
Open Dialog
...
```
---
## Dialog light dismiss without click-outside listeners
URL: https://modern-css.com/dialog-light-dismiss-without-click-outside-listeners/
Category: HTML
Difficulty: Beginner
Baseline: limited
Browser support: 69%
MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#closedby
Closing a dialog when the user clicks outside required a JavaScript click listener on the backdrop. The closedby attribute gives dialogs popover-style light dismiss behavior natively.
### How it works
The element didn't have a built-in way to close when clicking outside. Developers had to add a click event listener, check if the click was outside the dialog's bounding rect (since clicking the ::backdrop still fires on the dialog), and then call dialog.close(). This was error-prone and needed extra code for ESC handling too.
The closedby attribute, available from Chrome 134, brings the popover's light dismiss pattern to dialogs. Set closedby="any" for full light dismiss (backdrop click + ESC), closedby="closerequest" for ESC-only, or closedby="none" to disable user-triggered closing entirely. The browser handles all the hit-testing and keyboard events natively.
### Why use this
- One attribute: Add closedby="any" and the browser handles backdrop clicks and ESC key. No JS event wiring.
- Consistent behavior: Works like popover light dismiss. Users get the same close-on-click-outside pattern everywhere.
- Three modes: none (default), closerequest (ESC only), or any (ESC + backdrop click). Pick the right behavior per dialog.
### Modern CSS
```css
Click outside or press ESC to close
```
---
## Customizable selects without a JavaScript library
URL: https://modern-css.com/customizable-selects-without-a-javascript-library/
Category: HTML
Difficulty: Intermediate
Baseline: limited
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/appearance
Styling selects used to require JavaScript libraries like Select2 or Choices.js that replace the native element entirely. Now appearance: base-select unlocks full CSS customization of the native select.
### How it works
Custom-styled selects have been one of the biggest pain points in web development. The native element was essentially un-stylable, so libraries like Select2 and Choices.js replaced it with a fully custom DOM structure. This added weight, broke accessibility, and required constant maintenance.
With appearance: base-select, you opt into the new customizable select mode. The browser provides a minimal, fully stylable foundation. You can style the button, the dropdown (::picker(select)), individual options, and even use the element to reflect the selected option's HTML in the button. Rich content like images and icons work inside options natively.
### Why use this
- Native element: Keep the real select with built-in keyboard navigation, form participation, and accessibility for free.
- Top-layer rendering: The dropdown renders in the top layer. No overflow clipping from parent containers.
- Zero JS, zero dependencies: Drop the 30 KB library. Style everything with CSS alone: button, list, options, even the selected content.
### Modern CSS
```css
select,
select ::picker(select) {
appearance: base-select;
}
select option:checked {
background: var(--accent);
}
```
---
## Vivid colors beyond sRGB
URL: https://modern-css.com/vivid-colors-beyond-srgb/
Category: Color
Difficulty: Intermediate
Baseline: newly (2023)
Browser support: 90%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch
The old way was hex, rgb(), or hsl(), all stuck in sRGB. On P3 or other wide-gamut screens, those colors look flat. oklch and color(display-p3 ...) unlock the extra range.
### How it works
The old way: every color you set with hex, rgb(), or hsl() lives in the sRGB gamut. On phones and laptops with P3 or other wide-gamut displays, the screen can show more saturated reds, greens, and oranges, but the browser was only given sRGB values, so things look a bit washed out.
The modern way: use oklch(0.7 0.25 29) for a perceptually uniform color that can go beyond sRGB when the display allows it, or color(display-p3 1 0.2 0.1) to target the P3 gamut directly. Browsers that support it will show the extra range; others will clamp to what they can show. You get richer color where it matters, without breaking older screens.
### Why use this
- Wide gamut: oklch and display-p3 can use colors outside sRGB. On P3 displays, oranges and greens really pop.
- Predictable: oklch is perceptually uniform. Tweaking lightness or chroma feels consistent; no weird HSL surprises.
- Future-proof: Browsers map out-of-gamut to the display. Same code works on sRGB and P3; P3 gets the extra range where available.
### Modern CSS
```css
.hero {
color: oklch(0.7 0.25 29);
}
```
---
## Color variants without Sass functions
URL: https://modern-css.com/color-variants-without-sass-functions/
Category: Color
Difficulty: Advanced
Baseline: newly (2024)
Browser support: 87%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors
The old way used Sass functions like lighten(), darken(), and saturate() at compile time. Relative color syntax derives variants from a variable at runtime with oklch(from var(--x) ...).
### How it works
The old way was Sass (or similar): lighten($brand, 20%), darken($brand, 10%), saturate($brand, 5%). Those run at compile time and output static hex or rgb. If --brand changes at runtime (e.g. theme switch), you need to precompute every variant and output separate values.
The modern way is relative color syntax. You write something like oklch(from var(--brand) calc(l + 0.2) c h): take the value of var(--brand), interpret it in oklch, and create a new color with lightness increased by 0.2 and chroma and hue unchanged. It runs in the browser. Change --brand and the variant updates. No preprocessor required.
### Why use this
- Runtime: Variants are computed from CSS variables when the style is applied. Change --brand and all derivatives update.
- No preprocessor: No Sass or build step. Pure CSS, works with any pipeline and in browser devtools.
- Full control: Adjust lightness (l), chroma (c), or hue (h) in oklch. Same idea as lighten/darken but with a proper color model.
### Modern CSS
```css
.btn {
background: oklch(from var(--brand) calc(l + 0.2) c h);
}
```
---
## Multiline text truncation without JavaScript
URL: https://modern-css.com/multiline-text-truncation-without-javascript/
Category: Typography
Difficulty: Beginner
Baseline: widely (2021)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp
The old way was JS that counted characters or words and appended "...", or the -webkit-line-clamp plus -webkit-box-orient combo. line-clamp is now in the spec and gives you clean multiline truncation.
### How it works
The old way was either JavaScript that cut the string at a character or word limit and appended "..." (and often broke on resize or different font sizes), or the CSS hack: display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical. The latter worked but relied on a non-standard property and had to be remembered as a set.
The modern way: the spec has line-clamp. You still use display: -webkit-box and -webkit-line-clamp: 3 for broad support, and add line-clamp: 3 so you're using the standard name. overflow: hidden does the rest. No JavaScript, truncation by line count, ellipsis by the browser.
### Why use this
- No character math: You choose the line count. The browser adds the ellipsis. No JS, no slicing, no resize listeners.
- Spec-backed: line-clamp is in the CSS spec. You keep -webkit-line-clamp for support; same value, future-proof.
- Layout-based: Truncation is by lines, not characters. Works with any font size and container width.
### Modern CSS
```css
.card-title {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
```
---
## Drop caps without float hacks
URL: https://modern-css.com/drop-caps-without-float-hacks/
Category: Typography
Difficulty: Beginner
Baseline: limited
Browser support: 91%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/initial-letter
The old way used float, a large font-size, and manual line-height and margin, and it broke across browsers. initial-letter gives you a real drop cap in one property.
### How it works
The old way: style ::first-letter with float: left, a big font-size (e.g. 3em), line-height: 1, and margin-right to clear the text. It kind of worked but line wrapping and alignment varied by browser and font, and you had to tweak by eye.
The modern way: set initial-letter: 3 (or another number). The number is how many lines the letter sinks into. The browser sizes and positions it correctly. Use two values, e.g. 3 2, for sink and raise. Safari requires the -webkit- prefix. One property, predictable result.
### Why use this
- One property: No float, no guessing font-size or line-height. You say how many lines the letter sinks and the browser does the rest.
- Stable layout: Initial-letter is designed for drop caps. Wrapping and alignment stay consistent across browsers.
- Optional raise: initial-letter: 3 2 means sink 3 lines and raise 2. You get control without fragile hacks.
### Modern CSS
```css
.drop-cap::first-letter {
-webkit-initial-letter: 3;
initial-letter: 3;
}
```
---
## Positioning shorthand without four properties
URL: https://modern-css.com/positioning-shorthand-without-four-properties/
Category: Layout
Difficulty: Beginner
Baseline: widely (2021)
Browser support: 93%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/inset
The old way was to set top, right, bottom, and left one by one for full-bleed or overlay layouts. inset gives you one property that sets all four.
### How it works
The old way: for a full-bleed overlay or any positioned element that should stretch to all edges, you set top: 0; right: 0; bottom: 0; left: 0. Four declarations, easy to miss one or get the order wrong.
The modern way: use inset: 0. It sets all four sides in one shot. You can use one value (all sides), two (vertical, horizontal), or four (top, right, bottom, left). Same syntax idea as margin or padding. Works with absolute, fixed, and sticky.
### Why use this
- One line: One property instead of four. Same effect, less repetition and fewer chances to forget a side.
- Same as margin/padding: inset follows the same multi-value pattern: one value for all, two for vertical/horizontal, four for each side.
- Readable: inset: 0 reads as "pin to all edges." Clear intent for overlays and full-bleed positioned elements.
### Modern CSS
```css
.overlay {
position: absolute;
inset: 0;
}
```
---
## Lazy rendering without IntersectionObserver
URL: https://modern-css.com/lazy-rendering-without-intersection-observer/
Category: Workflow
Difficulty: Intermediate
Baseline: newly (2024)
Browser support: 93%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility
The old way used JavaScript's IntersectionObserver to detect when elements entered the viewport, then loaded or rendered them. Now CSS handles it with content-visibility: auto.
### How it works
The old approach used JavaScript's IntersectionObserver API to watch elements as they scrolled into view. When an element crossed the viewport threshold, a callback would trigger rendering or loading. This required setup, teardown, and careful management of observer instances across the page.
With content-visibility: auto, the browser handles all of that internally. Offscreen content gets skipped during layout and paint, and contain-intrinsic-size provides a placeholder height so the scrollbar stays stable. When the user scrolls near the content, the browser renders it automatically.
### Why use this
- Faster page loads: The browser skips layout and paint for offscreen content. Pages with long lists render significantly faster.
- No JavaScript needed: IntersectionObserver requires setup code, callbacks, and cleanup. This is two CSS properties.
- Automatic size estimation: The auto keyword in contain-intrinsic-size remembers the real size after first render, so scrollbar height stays accurate.
### Modern CSS
```css
.section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
```
---
## Dropdown menus without JavaScript toggles
URL: https://modern-css.com/dropdown-menus-without-javascript-toggles/
Category: HTML
Difficulty: Beginner
Baseline: newly (2024)
Browser support: 86%
MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover
The old way used JS for toggle, click-outside, and ESC, plus manual aria. The popover attribute and popovertarget give you a built-in dismissible popover with no toggle code.
### How it works
The old way: a click handler toggles a class that shows or hides the menu. You add a document click listener for click-outside, a keydown listener for Escape, and you manage aria-expanded and aria-hidden yourself. It's easy to miss an edge case.
The modern way: put popover on the menu element and popovertarget="menu" on the button. The button becomes a popover trigger. Opening, closing, click-outside, and ESC are built in. The menu is rendered in the top layer. You only need CSS to position and style it.
### Why use this
- Built-in toggle: Click the button to open, click outside or press ESC to close. No event listeners.
- Top layer: Popover goes in the top layer. No z-index fights with the rest of the page.
- Accessible: The platform handles focus and dismiss. You style the popover and wire the button with popovertarget.
### Modern CSS
```css
#menu[popover] {
position: absolute;
margin: 0.25rem 0;
}
```
---
## Tooltip positioning without JavaScript
URL: https://modern-css.com/tooltip-positioning-without-javascript/
Category: Layout
Difficulty: Advanced
Baseline: limited
Browser support: 77%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning
The old way relied on JS libraries like Popper.js or Floating UI to compute tooltip position. CSS anchor positioning ties the tooltip to its trigger with anchor-name and position-anchor.
### How it works
The old way was to use a library like Popper.js or Floating UI: JavaScript reads the trigger and tooltip rects, computes top/left, sets them (often via CSS variables or inline styles), and subscribes to scroll and resize to update. It works but adds weight and complexity.
The modern way is CSS anchor positioning. Give the trigger anchor-name: --tip. On the tooltip, set position-anchor: --tip and use top: anchor(bottom) (or other anchor() sides). The browser keeps the tooltip positioned relative to the trigger. No JavaScript required.
The killer feature for real-world use is position-try-fallbacks. If the tooltip would overflow the viewport when placed below the trigger, list fallbacks like position-try-fallbacks: flip-block, flip-inline and the browser tries each in order, picking the first that fits. This replaces the most complex part of every positioning library.
Anchor positioning handles placement only — show/hide still needs a toggle. Pair it with the popover attribute on the tooltip and popovertarget on the trigger for a fully accessible, zero-JS show/hide pattern. Use @supports (anchor-name: --x) to gate styles for older browsers.
### Why use this
- No layout thrash: The browser keeps tooltip and trigger in sync. No JS measuring or scroll listeners.
- Declarative: You say where the tooltip goes relative to the anchor. The engine does the math.
- Drop the library: Popper.js and Floating UI are great, but for simple tooltips you can skip them entirely.
### Modern CSS
```css
.trigger { anchor-name: --tip; }
.tooltip { position-anchor: --tip; top: anchor(bottom); }
```
---
## Scoped styles without BEM naming
URL: https://modern-css.com/scoped-styles-without-bem-naming/
Category: Workflow
Difficulty: Advanced
Baseline: newly (2024)
Browser support: 84%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@scope
To avoid leaks you used BEM, CSS Modules, or styled-components. @scope limits selectors to a root and optional boundary so you can use short names safely.
### How it works
Global CSS meant long class names (BEM like .card__title) or a system that generated unique names (CSS Modules, styled-components). The goal was to avoid one component's .title affecting another.
@scope (.card) { .title { … } } makes .title only match elements inside a .card. You can use simple class names and keep styles local. Add a boundary with @scope (.panel) to (.slot) so the scope doesn't cross into nested components that should stay independent.
### Why use this
- Short names: Use .title and .body. They only apply inside the scoped root, so no collisions.
- No build: No CSS Modules hash or styled-components runtime. Plain CSS, native in the browser.
- Boundary: Optional to (.root) to (.boundary) so inner components don't get styled by outer scope.
### Modern CSS
```css
@scope (.card) {
.title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.body { color: #444; }
}
```
---
## Typed custom properties without JavaScript
URL: https://modern-css.com/typed-custom-properties-without-javascript/
Category: Workflow
Difficulty: Advanced
Baseline: newly (2024)
Browser support: 92%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@property
Custom properties were strings. You couldn't animate them or get browser validation. @property gives you a type, so the browser can interpolate and validate.
### How it works
Custom properties were untyped. The browser stored them as strings, so you couldn't transition from 0 to 360 for a hue, and invalid values weren't caught. You needed JS to tween or validate.
@property --name { syntax: ""; inherits: false; initial-value: 0deg; } registers the variable with a type. The browser can interpolate it in transitions and animations and will reject invalid values. Syntax can be , , , and more.
### Why use this
- Animatable: Transition or animate custom properties. The browser interpolates by type.
- Validated: Invalid values fall back to initial-value. No silent string surprises.
- No JS: No script to parse or tween. Pure CSS for hue, length, or number transitions.
### Modern CSS
```css
@property --hue {
syntax: "";
inherits: false;
initial-value: 0deg;
}
.wheel {
background: hsl(var(--hue), 80%, 50%);
transition: --hue .3s;
}
```
---
## Independent transforms without the shorthand
URL: https://modern-css.com/independent-transforms-without-the-shorthand/
Category: Animation
Difficulty: Beginner
Baseline: widely (2022)
Browser support: 92%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/translate
transform was one shorthand. To change only rotation you had to repeat translate and scale. Now translate, rotate, and scale are separate properties you can animate on their own.
### How it works
With transform: translateX(10px) rotate(45deg) scale(1.2), changing just the angle on hover meant repeating the whole list. Easy to get out of sync or miss a value.
The individual properties translate, rotate, and scale do the same thing but live on their own. You can set or animate any one without touching the others. They still combine into one transform in a fixed order: translate, then rotate, then scale.
### Why use this
- Change one, keep the rest: Update only rotate or scale. No copying the whole transform string.
- Easier animation: Animate translate and rotate in different keyframes or with different timing.
- Same order: translate, rotate, scale always apply in that order. No shorthand order gotchas.
### Modern CSS
```css
.icon {
translate: 10px 0;
rotate: 45deg;
scale: 1.2;
}
.icon:hover {
rotate: 90deg;
}
```
---
## Animating display none without workarounds
URL: https://modern-css.com/animating-display-none-without-workarounds/
Category: Animation
Difficulty: Intermediate
Baseline: newly (2024)
Browser support: 85%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior
You couldn't transition display. Workarounds were JS that set display: none after transition end, or visibility plus opacity and pointer-events. Now discrete properties can participate.
### How it works
display isn't interpolable, so it was left out of transitions. To hide after a fade you either listened for transitionend in JS and then set display: none, or you kept the element in layout with visibility: hidden and pointer-events: none so it didn't block clicks.
transition-behavior: allow-discrete lets discrete properties like display and overlay participate. The browser runs the interpolable transition (e.g. opacity), then flips the discrete value at the right moment. No JS.
### Why use this
- Real display: Animate to display: none. No need to keep the element in the layout with visibility.
- No transitionend: Browser handles the discrete flip at the right time. No JS listening for transitionend.
- Overlay too: overlay is another discrete property. Useful for popovers and modals.
### Modern CSS
```css
.panel {
transition: opacity .2s, overlay .2s allow-discrete;
transition-behavior: allow-discrete;
}
.panel.hidden {
opacity: 0;
display: none;
}
```
---
## Entry animations without JavaScript timing
URL: https://modern-css.com/entry-animations-without-javascript-timing/
Category: Animation
Difficulty: Intermediate
Baseline: newly (2024)
Browser support: 85%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style
Transitions only ran when a value changed. To animate in, you added a class in JS after paint. @starting-style defines the before state so CSS can transition from it.
### How it works
CSS transitions only run when a property changes. If an element appears with opacity: 0 and you want it to transition to 1, the browser never saw the 0, so you had to set the initial state, then add a class in the next frame (requestAnimationFrame, or double rAF) so the change was detected.
@starting-style lets you define the style that applies at the moment the element is first rendered. The browser uses that as the from state and transitions to the element's actual styles. No JavaScript, no timing hacks.
### Why use this
- No JS timing: No double rAF or setTimeout. The browser knows the starting state from CSS.
- Declarative: Entry and transition live in one place. Works with dynamic content and frameworks.
- Same transition: Uses your existing transition properties. No separate keyframes for enter.
### Modern CSS
```css
.card {
transition: opacity .3s, transform .3s;
@starting-style {
opacity: 0;
transform: translateY(10px);
}
}
```
---
## Page transitions without a framework
URL: https://modern-css.com/page-transitions-without-a-framework/
Category: Animation
Difficulty: Advanced
Baseline: newly (2024)
Browser support: 89%
MDN: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
Page transitions used to need Barba.js or React Transition Group. The View Transitions API gives you cross-fades and shared-element motion with one JS call and CSS.
### How it works
Smooth page-to-page or view-to-view transitions usually meant a library that ran leave animations, swapped content, then ran enter animations. You managed state and timing yourself.
document.startViewTransition(callback) runs your callback (e.g. update the DOM), and the browser captures the before state, applies the update, captures the after state, then animates between them. Use view-transition-name on elements that should match across views for shared-element effects. Style with ::view-transition-old() and ::view-transition-new().
Always respect prefers-reduced-motion. Wrap transition animations in a @media (prefers-reduced-motion: no-preference) block, or use @media (prefers-reduced-motion: reduce) { ::view-transition-group(*) { animation: none; } } to disable transitions for users who have opted out of motion.
For same-document transitions the API is widely supported. Cross-document view transitions (navigating between full pages without a SPA) require the @view-transition { navigation: auto; } CSS rule and are still rolling out. If the API is unavailable, the callback still runs normally — the DOM update happens without animation, making this a zero-cost progressive enhancement.
### Why use this
- One API: Wrap your DOM update in startViewTransition. Browser handles capture, transition, and paint.
- Shared elements: view-transition-name links old and new elements. Morph between them with CSS.
- Framework-agnostic: Works with vanilla JS, React, or any stack. No router lock-in.
### Modern CSS
```css
document.startViewTransition(() => {
document.body.innerHTML = newContent;
});
.hero { view-transition-name: hero; }
```
---
## Scroll snapping without a carousel library
URL: https://modern-css.com/scroll-snapping-without-a-carousel-library/
Category: Layout
Difficulty: Intermediate
Baseline: widely (2020)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type
Carousels used to mean Slick, Swiper, or custom scroll math. CSS scroll snap gives you card-by-card or full-width snapping with a few properties.
### How it works
Carousels were usually built with a JS library that handled scroll position, touch events, and snap calculations. You paid in bundle size and maintenance.
scroll-snap-type: x mandatory on a scroll container plus scroll-snap-align: start (or center) on each item gives you snapping. Use overflow-x: auto and flex/grid for the layout. No JS.
### Why use this
- No library: No Slick, Swiper, or custom scroll math. Just CSS on a scroll container.
- Native touch: Touch and trackpad scrolling work out of the box. No touchstart handlers.
- Accessible: Real overflow scroll, so keyboard and screen readers get normal scroll behavior.
### Modern CSS
```css
.carousel {
scroll-snap-type: x mandatory;
overflow-x: auto;
display: flex;
gap: 1rem;
}
.carousel > * { scroll-snap-align: start; }
```
---
## Balanced headlines without manual line breaks
URL: https://modern-css.com/balanced-headlines-without-manual-line-breaks/
Category: Typography
Difficulty: Beginner
Baseline: newly (2023)
Browser support: 87%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/text-wrap
You used to add br tags by hand or pull in Balance-Text.js. Now one property evens out lines so headlines don't end with a single word.
### How it works
Headlines often wrapped with one word on the last line. The old fix was either manual tags (brittle when copy changes) or a script like Balance-Text that measured and reflowed after paint.
text-wrap: balance tells the browser to prefer more even line lengths. It works best on short blocks (a few lines). No script, no DOM access, just layout.
### Why use this
- No scripts: Drop Balance-Text or any JS. The browser balances lines natively.
- No manual br tags: Works with any content width. No CMS hacks or hand-placed line breaks.
- Performance: Layout happens in the engine. No DOM reads or resize observers.
### Modern CSS
```css
h1, h2 {
text-wrap: balance;
max-width: 40ch;
}
```
---
## Font loading without invisible text
URL: https://modern-css.com/font-loading-without-invisible-text/
Category: Typography
Difficulty: Beginner
Baseline: widely (2019)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display
Custom fonts used to cause a flash of invisible text (FOIT) while they downloaded. font-display: swap shows the fallback right away, then swaps when the font is ready.
### How it works
Without font-display, the browser uses a default behavior that often hides text for a few seconds while the custom font loads. That is the flash of invisible text (FOIT). Users on slow connections see a blank area until the font file arrives.
Adding font-display: swap to your @font-face tells the browser to show the fallback font immediately and swap to the custom font when it is ready. Users can read right away; they may see a brief reflow when the font loads, but that is usually better than invisible text. Use it on every @font-face unless you have a reason to use optional or block.
### Why use this
- No FOIT: Users see fallback text right away instead of blank space while the font downloads.
- One line: Add font-display: swap to your existing @font-face. No JS or loading strategy needed.
- Other options: optional hides text if font is not cached; block gives a short invisible timeout. swap is the safe default.
### Modern CSS
```css
@font-face {
font-family: "MyFont";
src: url("MyFont.woff2");
font-display: swap;
}
```
---
## Multiple font weights without multiple files
URL: https://modern-css.com/multiple-font-weights-without-multiple-files/
Category: Typography
Difficulty: Intermediate
Baseline: widely (2018)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide
You loaded a separate font file for each weight (400, 500, 600, 700), so 4 or more HTTP requests. One variable font file covers the full range with font-weight: 100 900.
### How it works
The old approach was one @font-face per weight: Regular (400), Medium (500), SemiBold (600), Bold (700). Each pointed at a different file, so the browser made 4 or more requests and you had to add a new @font-face whenever you needed another weight.
Variable fonts pack multiple weights (and sometimes width or other axes) into a single file. In @font-face you set font-weight: 100 900 (or the range the font supports). The browser downloads one file and you use any weight in that range with font-weight: 400, font-weight: 600, etc. Check the font's documentation for its actual range.
### Why use this
- Fewer requests: One variable font file instead of four or more for Regular, Medium, SemiBold, Bold.
- Any weight in range: Use font-weight: 350 or 627 if the font supports it. No need to add a new @font-face.
- Smaller total size: One optimized file is often smaller than the sum of several static weight files.
### Modern CSS
```css
@font-face {
font-family: "MyVar";
src: url("MyVar.woff2");
font-weight: 100 900;
}
```
---
## Dark mode defaults without extra CSS
URL: https://modern-css.com/dark-mode-defaults-without-extra-css/
Category: Workflow
Difficulty: Beginner
Baseline: widely (2021)
Browser support: 93%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme
You used to restyle every form control, scrollbar, and background for dark mode. color-scheme tells the browser to use its built-in light or dark styling for those parts.
### How it works
The old approach was to add a @media (prefers-color-scheme: dark) block and manually set background and color on every input, select, textarea, and button, plus scrollbar styling. Lots of code and easy to miss a control.
color-scheme: light dark tells the browser that the page supports both schemes. The browser then uses its built-in light or dark styling for form controls, scrollbars, and the default canvas background, based on the user's preference. You still set your own colors for text and backgrounds; color-scheme handles the system-level parts so you do not have to.
### Why use this
- One declaration: The browser applies its native light or dark styling to form controls, scrollbars, and default backgrounds.
- No form control hacks: No need to override every input and select for dark. They follow the scheme automatically.
- Pairs with light-dark(): Use color-scheme with light-dark() or variables so your colors and system UI match.
### Modern CSS
```css
:root {
color-scheme: light dark;
}
```
---
## Dark mode colors without duplicating values
URL: https://modern-css.com/dark-mode-colors-without-duplicating-values/
Category: Color
Difficulty: Intermediate
Baseline: newly (2024)
Browser support: 83%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark
You defined every variable in :root and again inside @media (prefers-color-scheme: dark). light-dark() holds both values in one place so you do not repeat yourself.
### How it works
The old approach was to set your colors (or CSS variables) in :root, then open a @media (prefers-color-scheme: dark) block and redeclare every variable or property for dark mode. That meant every color in two places and easy drift between light and dark.
The modern approach is light-dark(lightValue, darkValue). The browser picks the first value in light mode and the second in dark mode, based on prefers-color-scheme. Pair it with color-scheme: light dark so form controls and scrollbars follow. You can use raw colors or variables: light-dark(var(--text), var(--text-dark)).
### Why use this
- No duplication: First argument is light, second is dark. One declaration, no @media block for each variable.
- Works with variables: light-dark(var(--text-light), var(--text-dark)) fits right into a design token setup.
- Any property: Use it for color, background, border-color, fill, stroke. Anything that takes a color.
### Modern CSS
```css
:root {
color-scheme: light dark;
color: light-dark(#111, #eee);
}
```
---
## Low-specificity resets without complicated selectors
URL: https://modern-css.com/low-specificity-resets-without-complicated-selectors/
Category: Selector
Difficulty: Intermediate
Baseline: widely (2021)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:where
Resets used verbose low-specificity tricks, or you fought them later with higher specificity. :where() has zero specificity so it never wins over your component styles.
### How it works
The old approach was either very specific selectors for resets (so they applied) or low-specificity hacks that were still hard to override. Tag selectors like ul, ol have specificity (0,0,2). A single class on a list like .list has (0,1,0), so it wins, but if your reset used a class or multiple elements, you often had to add more specificity or !important in components.
:where(ul, ol) accepts the same selector list as :is(), but the whole thing gets zero specificity. So your reset applies by order in the cascade, but any single class or ID in a component beats it. Use :where() for resets, base styles, and defaults you want to be easy to override.
### Why use this
- Zero specificity: :where() strips specificity from its argument. Your reset never overrides component styles.
- No !important: No need to bump specificity or use !important in components to beat the reset.
- Same syntax as :is(): Drop in :where() where you would use :is(). Same selector list, zero specificity.
### Modern CSS
```css
:where(ul, ol) {
margin: 0;
padding-inline-start: 1.5rem;
}
```
---
## Direction-aware layouts without left and right
URL: https://modern-css.com/direction-aware-layouts-without-left-and-right/
Category: Layout
Difficulty: Intermediate
Baseline: widely (2021)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values
You used margin-left, padding-right, border-left, then overrode everything for RTL with [dir="rtl"]. Logical properties are direction-aware so one set of rules works for both.
### How it works
The old approach was to use physical properties like margin-left, padding-right, border-left, then add a [dir="rtl"] block that reset and flipped them. That meant maintaining two sets of values and missing one caused layout bugs in RTL.
Logical properties map to the writing mode. margin-inline-start is left in LTR and right in RTL. border-block-start is top in horizontal writing mode. Use inline for start/end (left/right in LTR) and block for block-start/block-end (top/bottom). Set dir on the document or a container and the same CSS works for both directions.
### Why use this
- One set of rules: inline-start and block-start follow writing direction. No [dir="rtl"] overrides.
- Less code: Drop the RTL override block. Logical properties flip automatically when direction changes.
- Future-proof: Works for vertical writing modes too. block and inline mean the same in any direction.
### Modern CSS
```css
.box {
margin-inline-start: 1rem;
padding-inline-end: 1rem;
border-block-start: 1px solid;
}
```
---
## Naming grid areas without line numbers
URL: https://modern-css.com/naming-grid-areas-without-line-numbers/
Category: Layout
Difficulty: Beginner
Baseline: widely (2017)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-areas
The old way was floats with clearfix and margin math, or grid with line numbers. Template areas let you name regions like header, sidebar, main, and drop them in place.
### How it works
The old approach was either float-based layouts with clearfix and tricky margins, or grid with explicit grid-column and grid-row line numbers. Line numbers work but are hard to read and refactor.
The modern approach is grid-template-areas: you write a string that looks like your layout. Each quoted row lists area names; repeat a name to span columns. Then on each child you set grid-area: header (or whatever name). The layout is defined in one place and reads like a simple map.
### Why use this
- Readable layout: The grid structure is visible at a glance. No counting lines or guessing spans.
- One place to change: Add or remove a row in the template string. Child items use grid-area names only.
- No line math: No grid-column: 1 / 3 or grid-row: 2. Name the area and the browser places it.
### Modern CSS
```css
.layout {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
}
```
---
## Aligning nested grids without duplicating tracks
URL: https://modern-css.com/aligning-nested-grids-without-duplicating-tracks/
Category: Layout
Difficulty: Advanced
Baseline: newly (2023)
Browser support: 88%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Subgrid
Inner grids used to repeat the parent's column definitions to align. That was fragile and got out of sync. Subgrid inherits the parent's tracks so everything lines up.
### How it works
The old way was to give the inner grid the same grid-template-columns as the parent so things visually lined up. Any time you changed the parent's columns, you had to find and update every nested grid. Easy to miss one and end up with misaligned content.
With grid-template-columns: subgrid, the child grid does not define its own columns. It reuses the parent's track list. Add or change columns on the parent once, and every subgrid lines up. You can also use grid-template-rows: subgrid for row alignment.
### Why use this
- Single source of truth: Column tracks are defined once on the parent. Change the parent, nested content aligns automatically.
- No duplication: No copying 1fr 1fr 1fr or repeat() into every nested grid. Less code, fewer mismatches.
- Real alignment: Cards, lists, or forms that span the same columns actually line up across sections.
### Modern CSS
```css
.child-grid {
display: grid;
grid-template-columns: subgrid;
}
```
---
## Modal dialogs without a JavaScript library
URL: https://modern-css.com/modal-dialogs-without-a-javascript-library/
Category: HTML
Difficulty: Intermediate
Baseline: widely (2022)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
The old way was a custom overlay plus JavaScript for open/close, ESC key, click-outside, focus trap, and z-index. The dialog element and showModal() handle all of that.
### How it works
The old approach meant a fixed overlay div, JavaScript to open and close it, keydown listeners for Escape, click-outside detection, focus trapping so tab stays inside the modal, and careful z-index management. Easy to get wrong or forget a detail.
The modern approach is a single element. Call dialog.showModal() to open it: the browser puts it in the top layer, traps focus, and provides ESC and click-outside behavior. Style the ::backdrop pseudo-element for the dimmed background. No overlay div, no focus library.
### Why use this
- Built-in behavior: ESC to close, click outside to close, and focus trapping come free. No extra JS.
- Accessible by default: The browser manages focus and return focus. Top layer stacking is handled for you.
- One element: No overlay div, no z-index wars. Style dialog and ::backdrop and you are done.
### Modern CSS
```css
dialog {
padding: 1rem;
}
dialog::backdrop {
background: rgb(0 0 0 / .5);
}
```
---
## Styling form controls without rebuilding them
URL: https://modern-css.com/styling-form-controls-without-rebuilding-them/
Category: Color
Difficulty: Beginner
Baseline: widely (2022)
Browser support: 93%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/accent-color
The old way was appearance: none plus dozens of lines to rebuild the control. accent-color changes the native control color in one property.
### How it works
To style checkboxes and radios you used to set appearance: none and then rebuild the control with width, height, border, background, border-radius, and :checked states. Dozens of lines and you had to handle focus and accessibility yourself.
accent-color tells the browser which color to use for the control's accent (check mark, radio dot, range thumb). The control stays native, so focus and keyboard behavior are unchanged. One line, theme-aware if you use a variable.
### Why use this
- One property: Set the accent color. Checkboxes, radios, range, and progress use it. No rebuild.
- Native behavior kept: Focus, keyboard, and screen reader behavior stay intact. You only change the color.
- Theme friendly: Use a custom property. Dark mode or theme switch updates controls automatically.
### Modern CSS
```css
input[type="checkbox"],
input[type="radio"] {
accent-color: #7c3aed;
}
```
---
## Grouping selectors without repetition
URL: https://modern-css.com/grouping-selectors-without-repetition/
Category: Selector
Difficulty: Beginner
Baseline: widely (2021)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:is
Repeating .card h1, .card h2, .card h3 is verbose. :is(h1, h2, h3, h4) under .card does the same in one rule.
### How it works
The old way was to list every combination: .card h1, .card h2, .card h3, .card h4. Same prefix over and over. Adding h5 meant another comma and another .card h5.
.card :is(h1, h2, h3, h4) means .card plus any one of those. One prefix, one list. Change the list without touching the rest. :is() also keeps specificity predictable (the most specific selector in the list wins).
### Why use this
- No repetition: Write the shared part once. Add or remove items in the list without duplicating the prefix.
- Easier to read: Intent is clear: these headings inside .card get the same style.
- Takes specificity of argument: :is() uses the highest specificity in its list. Good to know when overriding.
### Modern CSS
```css
.card :is(h1, h2, h3, h4) {
margin-bottom: 0.5em;
}
```
---
## Focus styles without annoying mouse users
URL: https://modern-css.com/focus-styles-without-annoying-mouse-users/
Category: Selector
Difficulty: Beginner
Baseline: widely (2022)
Browser support: 95%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible
:focus shows an outline on every click, which looks wrong for mouse users. :focus-visible shows it only when the browser expects keyboard focus.
### How it works
With :focus, the outline appears whenever the element gets focus, including after a mouse click. That looks odd and many sites removed it with outline: none, which hurts keyboard users.
:focus-visible only matches when the browser would normally show a focus ring, e.g. after Tab. Mouse users don't see it, keyboard users do. You get clear focus styles without the old tradeoff.
### Why use this
- Keyboard only: Outline shows when focus comes from Tab, not from a mouse click. Matches user intent.
- Accessible by default: You keep visible focus for keyboard users. No need to remove outline and hurt a11y.
- Browser decides: Browsers use heuristics (keyboard vs pointer). One selector, correct behavior.
### Modern CSS
```css
button:focus-visible {
outline: 2px solid var(--focus-color);
}
```
---
## Controlling specificity without !important
URL: https://modern-css.com/controlling-specificity-without-important/
Category: Workflow
Difficulty: Intermediate
Baseline: widely (2022)
Browser support: 95%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer
The old way was stacking more specific selectors or throwing !important. @layer lets you decide order without fighting specificity.
### How it works
Previously you made selectors more specific (e.g. .page .card .title) or used !important to force overrides. That led to specificity wars and hard-to-debug styles.
@layer base, components, utilities defines the order. Whatever comes later wins when specificity is equal. Put resets in base, components in components, and utility classes in utilities. No !important, no long selectors.
### Why use this
- Order over specificity: Later layers win. No need to make selectors more specific to override.
- Predictable cascade: base, then components, then utilities. Same order every time.
- No !important: Utilities can override components with a single class. Clean and explicit.
### Modern CSS
```css
@layer base, components, utilities;
@layer utilities {
.mt-4 { margin-top: 1rem; }
}
```
---
## Theme variables without a preprocessor
URL: https://modern-css.com/theme-variables-without-a-preprocessor/
Category: Workflow
Difficulty: Beginner
Baseline: widely (2017)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties
Sass and LESS variables compile to static values. Custom properties live in the browser and can change at runtime.
### How it works
Preprocessor variables like $primary: #7c3aed are replaced at compile time. The output is plain hex. To switch themes you recompile or generate multiple stylesheets.
Custom properties (--primary: #7c3aed) are real CSS. You read and set them with var(--primary). Toggle a class on the root or use JS to change --primary and every reference updates. No build, no duplicate CSS.
### Why use this
- Runtime updates: Change --primary in JS or a class and every use updates. No rebuild.
- No build step: Plain CSS. Works in any environment, no Sass or LESS required.
- Cascade and override: Set on :root, override in .dark or a component. Inherits like normal CSS.
### Modern CSS
```css
:root {
--primary: #7c3aed;
--spacing: 16px;
}
.btn { background: var(--primary); }
```
---
## Fluid typography without media queries
URL: https://modern-css.com/fluid-typography-without-media-queries/
Category: Typography
Difficulty: Intermediate
Baseline: widely (2021)
Browser support: 95%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/clamp
The old way used several media queries with different font-sizes. clamp() scales smoothly between a min and max.
### How it works
The old approach was a base font-size plus several media queries: at 600px switch to 1.5rem, at 900px to 2rem, and so on. Size jumped at each breakpoint and you had to maintain the ladder.
clamp(1rem, 2.5vw, 2rem) means: never smaller than 1rem, never larger than 2rem, and in between use 2.5vw. One rule, smooth scaling, no media queries.
### Why use this
- No breakpoint ladder: One declaration. Size scales with viewport instead of jumping at breakpoints.
- Smooth scaling: clamp(min, preferred, max) keeps size between bounds. No sudden jumps.
- Any unit: Use rem, em, vw, or a mix. Same pattern for line-height or spacing.
### Modern CSS
```css
h1 {
font-size: clamp(1rem, 2.5vw, 2rem);
}
```
---
## Spacing elements without margin hacks
URL: https://modern-css.com/spacing-elements-without-margin-hacks/
Category: Layout
Difficulty: Beginner
Baseline: widely (2021)
Browser support: 95%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/gap
The old way used margins on children and negative margin on the container, or :last-child to cancel the last margin. Gap handles it on the parent.
### How it works
The old pattern was margin on every child (e.g. margin-right: 16px) and then a :last-child rule to set margin to 0 so the last item didn't add extra space. Or you used negative margin on the container to absorb the last child's margin. Both are fiddly.
With display: flex or grid and gap: 16px, the browser adds space only between items. No child margins, no overrides. Works the same for rows and columns, and you can use row-gap and column-gap separately if needed.
### Why use this
- No edge cases: Gap only goes between items. No need to zero out the last child or use negative margins.
- Parent controls spacing: One value on the container. Add or remove children, spacing stays consistent.
- Flex and grid: Same gap property works for both. Row and column gap available too.
### Modern CSS
```css
.grid {
display: flex;
gap: 16px;
}
```
---
## Aspect ratios without the padding hack
URL: https://modern-css.com/aspect-ratios-without-the-padding-hack/
Category: Layout
Difficulty: Beginner
Baseline: widely (2021)
Browser support: 93%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio
The old trick used padding-top: 56.25% and nested absolute positioning. aspect-ratio does it in one declaration.
### How it works
The classic trick was a wrapper with padding-top: 56.25% (100/16*9 for 16:9) and position: relative, then a child with position: absolute; inset: 0 to fill it. Two selectors, magic numbers, and the child had to be taken out of flow.
aspect-ratio: 16 / 9 on the container does the job. The element keeps that ratio as width changes. No padding hack, no absolute child, no percentage math.
### Why use this
- No math: 16/9, 4/3, 1/1. No percentage math or nested wrappers.
- Single element: One container, one property. Content can sit inside without absolute positioning.
- Any ratio: Use any ratio you need. Works with flex and grid too.
### Modern CSS
```css
.video-wrapper {
aspect-ratio: 16 / 9;
}
```
---
## Sticky headers without JavaScript scroll listeners
URL: https://modern-css.com/sticky-headers-without-javascript-scroll-listeners/
Category: Layout
Difficulty: Beginner
Baseline: widely (2022)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/position
The old way used JavaScript scroll events and getBoundingClientRect to toggle a class. Sticky positioning does it in one property.
### How it works
The old way meant a scroll listener, reading getBoundingClientRect on every scroll, and toggling a class to switch between normal and fixed. That's JS, reflows, and extra CSS for the fixed state.
With position: sticky and top: 0, the header stays in flow until it would scroll past the top, then it sticks. The browser does the work. No script, no class, no layout hacks.
### Why use this
- No JavaScript: The browser handles scroll. No listeners, no getBoundingClientRect, no class toggles.
- Respects flow: Sticky stays in layout until it hits the threshold, then sticks. No layout jumps.
- One property: Set position and top. Works for headers, sidebars, or any element you want to pin.
### Modern CSS
```css
.header {
position: sticky;
top: 0;
}
```
---
## Scroll-linked animations without a library
URL: https://modern-css.com/scroll-linked-animations-without-a-library/
Category: Animation
Difficulty: Advanced
Baseline: limited
Browser support: 78%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline
Fade-in-on-scroll used to mean IntersectionObserver, GSAP, or AOS.js. CSS can now trigger animations based on scroll position, zero JavaScript, smooth 60fps.
### How it works
animation-timeline: view() binds a standard @keyframes animation to the element's visibility in the scroll port. As the element scrolls into view, the animation progresses from 0% to 100%.
animation-range: entry 0% entry 100% means the animation plays fully during the "entry" phase, from the moment the element's edge appears to when it's fully visible. You can also use exit, cover, or contain ranges.
Since this uses the browser's animation engine (compositor thread), it's inherently smoother than any JavaScript approach that mutates styles on the main thread.
Always wrap scroll-driven animations in @media (prefers-reduced-motion: no-preference). Scroll-linked motion is particularly likely to cause discomfort for vestibular disorder users because it ties movement directly to a physical gesture rather than a discrete trigger.
For scroll-linked progress effects (e.g. a reading progress bar), use animation-timeline: scroll() instead of view(). scroll() tracks the scroll container's total progress; view() tracks a specific element's position within the viewport. Both share the same animation-range syntax.
### Why use this
- GPU-accelerated: Runs on the compositor thread, 60fps guaranteed. No main-thread jank from JS observers or scroll handlers.
- No JavaScript at all: Drop GSAP, AOS.js, or custom IntersectionObserver code. Entire scroll animation in 4 lines of CSS.
- Reversible by default: Scroll back up and the animation reverses naturally. No need for "unobserve" or state management.
### Modern CSS
```css
@keyframes reveal {
from { opacity: 0; translate: 0 40px; }
to { opacity: 1; translate: 0 0; }
}
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
```
---
## Nesting selectors without Sass or Less
URL: https://modern-css.com/nesting-selectors-without-sass-or-less/
Category: Workflow
Difficulty: Beginner
Baseline: newly (2023)
Browser support: 91%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Nesting
Selector nesting was the #1 reason people reached for Sass. It's now built into CSS and part of Baseline 2023. Same & syntax, zero build tools.
### How it works
CSS nesting lets you write child selectors inside parent rule blocks using the & symbol, just like Sass. The browser interprets .nav { & a { … } } as .nav a { … }.
You can nest pseudo-classes (&:hover), pseudo-elements (&::before), and even media/container queries inside a rule block. The & always refers to the parent selector.
Unlike the initial release, the relaxed nesting syntax now matches Sass: you can write bare element selectors like a { color: red } directly inside a parent. The & is only required for compound selectors like &:hover or &.active.
### Why use this
- No build step: Drop Sass, Less, PostCSS, or any compiler. Ship CSS directly to the browser, nesting included.
- Familiar syntax: Same & nesting you already know from Sass. Near-zero learning curve for existing teams.
- Smaller toolchain: One fewer dependency in your build. Faster installs, simpler CI, fewer things to break.
### Modern CSS
```css
.nav {
display: flex;
gap: 8px;
& a {
color: #888;
text-decoration: none;
&:hover {
color: white;
}
}
}
```
---
## Responsive components without media queries
URL: https://modern-css.com/responsive-components-without-media-queries/
Category: Layout
Difficulty: Intermediate
Baseline: widely (2023)
Browser support: 93%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries
Media queries respond to the viewport. Components live in containers: sidebars, modals, grids. @container lets them respond to their actual available space.
### How it works
First, mark a parent as a container with container-type: inline-size. This tells the browser to track its width for query purposes.
Then use @container (width > 400px) instead of @media. The query now fires based on the container's width, not the viewport. Your card can be 300px wide in a sidebar and 800px wide in a main area, and it'll adapt correctly in both.
You can even name containers with container-name to target specific ancestors when nesting multiple containers.
### Why use this
- Component-level responsive: Components adapt to their own container. Same card works in a sidebar, modal, or full-width grid.
- Truly reusable: No more breakpoint math for every context. The component carries its responsive logic with it.
- Precise control: Query the nearest container, not the whole viewport. Each section of the page can behave independently.
### Modern CSS
```css
.wrapper {
container-type: inline-size;
}
.card {
display: grid;
grid-template-columns: 1fr;
}
@container (width > 400px) {
.card {
grid-template-columns: auto 1fr;
}
}
```
---
## Mixing colors without a preprocessor
URL: https://modern-css.com/mixing-colors-without-a-preprocessor/
Category: Colors
Difficulty: Intermediate
Baseline: newly (2023)
Browser support: 89%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix
Blending two colors used to require Sass, Less, or a JS utility. color-mix() does it in plain CSS, and it supports perceptually uniform color spaces like oklch.
### How it works
color-mix(in oklch, color1 percentage, color2) blends two colors in the specified color space. The percentage controls how much of the first color to use.
The oklch color space is perceptually uniform, meaning a 50/50 mix of blue and yellow actually looks like a midpoint, unlike sRGB mixing which often produces muddy results. You can also use srgb, hsl, lab, or lch.
The best part: combine it with custom properties for dynamic theming. color-mix(in oklch, var(--brand) 80%, white) gives you a lighter variant of any brand color, at runtime, no build step.
### Why use this
- Perceptually uniform: Mix in oklch for results that look natural to the human eye, unlike Sass's sRGB mixing.
- Dynamic at runtime: Change the mix with custom properties, no recompilation. Works with themes, dark mode, anything.
- No build step: Drop Sass, PostCSS, or any preprocessor for color manipulation. Native CSS does it now.
### Modern CSS
```css
.card {
background: color-mix(in oklch, #3b82f6 60%, #ec4899);
}
```
---
## Selecting parent elements without JavaScript
URL: https://modern-css.com/selecting-parent-elements-without-javascript/
Category: Selectors
Difficulty: Intermediate
Baseline: newly (2023)
Browser support: 94%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:has
Selecting a parent based on its children used to require JavaScript. :has() handles it in pure CSS, no event listeners needed.
### How it works
:has() is a relational pseudo-class. When you write .card:has(img), it selects any .card that contains an img as a descendant. It's the parent selector that CSS lacked for 25 years.
You can use any selector inside :has(): pseudo-classes like :invalid, :checked, :hover, or even combinators like :has(> .direct-child). It unlocks conditional styling that previously required JavaScript.
### Why use this
- No JavaScript needed: Eliminates an entire class of DOM-manipulation code. Fewer event listeners, fewer bugs.
- Instant response: Browser applies styles in the rendering pipeline, no waiting for JS execution or reflow.
- Composes naturally: Chain with other selectors: .nav:has(.dropdown:hover), body:has(dialog[open]).
### Modern CSS
```css
.form-group:has(input:invalid) {
border-color: red;
background: #fff0f0;
}
```
---
## Centering elements without the transform hack
URL: https://modern-css.com/centering-elements-without-the-transform-hack/
Category: Layout
Difficulty: Beginner
Baseline: widely (2020)
Browser support: 96%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/place-items
The old absolute + transform centering trick took 5 declarations across 2 selectors. Grid does it in 2 properties on the parent.
### How it works
The old approach requires position: relative on the parent and position: absolute + top: 50%; left: 50%; transform: translate(-50%, -50%) on the child. That's 5 declarations across 2 selectors, and the child gets pulled out of normal flow.
The modern approach uses display: grid on the parent and place-items: center, a shorthand for align-items + justify-items. The child stays in flow, and you don't touch the child's styles at all.
### Why use this
- Less code: 2 properties on the parent vs. 5+ across two selectors. Fewer rules, fewer bugs.
- Stays in flow: No position: absolute means the child stays in document flow. Layout stays predictable.
- Works on anything: Text, images, divs, forms, anything gets centered. No need to know dimensions.
### Modern CSS
```css
.parent {
display: grid;
place-items: center;
}
```
---
## Pinterest-style layouts without a JavaScript library
URL: https://modern-css.com/css-grid-lanes/
Category: Layout
Difficulty: Beginner
Baseline: limited
Browser support: 11%
MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Grid_lanes
Uneven cards and images used to mean Masonry.js measuring every item, or CSS columns that read top to bottom. display: grid-lanes packs each item into the shortest lane in CSS.
### How it works
display: grid-lanes keeps one axis as a normal grid and packs the other. grid-template-columns makes a waterfall: items drop into the shortest column. grid-template-rows makes a brick wall: items slide into the shortest row. Browsers that do not recognize grid-lanes drop that declaration, so a display: grid line above it keeps a regular grid as the fallback.
The earlier prototypes, display: masonry and grid-template-rows: masonry, are no longer the spec. flow-tolerance (default 1em) treats near-equal lane lengths as a tie so visual order stays closer to source order. Safari 26.4 ships this unflagged. Chrome, Edge, and Firefox still hide it behind a flag.
### Why use this
- The browser packs the lanes: Each item goes into whichever column or row has the most room. No measuring, no resize observers, no relayout after images load.
- Source order stays left to right: CSS columns fill top to bottom, so tab order jumps down a column. Grid lanes keep document order across the row, then pack vertically.
- Still CSS Grid: gap, span, line-based placement, and repeat(auto-fill, minmax()) all work.
### Modern CSS
```css
.gallery {
display: grid;
display: grid-lanes;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
```
---