Modern CSS Features
Explore the latest CSS features that are transforming how we build for the web — container queries, :has(), cascade layers, native nesting, and much more.
Container Queries
Container queries let components respond to their parent container's size, not the viewport. This makes truly reusable, context-aware components possible.
/* 1. Define a containment context */ .card-container { container-type: inline-size; /* respond to width */ container-name: card-wrap; /* optional: name the container */ } /* Shorthand */ .card-container { container: card-wrap / inline-size; } /* 2. Query the container */ @container (min-width: 400px) { .card { display: grid; grid-template-columns: 200px 1fr; } } /* Query a named container */ @container card-wrap (min-width: 600px) { .card__title { font-size: 1.5rem; } } /* container-type values: inline-size — query inline dimension (width in horizontal) size — query both dimensions normal — no containment (default) */
Drag the bottom-right corner to resize the container:
Container Query Card
This card's layout changes based on the container width, not the viewport. Narrow = stacked, wide = side-by-side. Resize the dashed border to see it in action.
:has() — The Parent Selector
The :has() pseudo-class selects an element based on its descendants or subsequent siblings. Often called the "parent selector" — something CSS lacked for over 20 years.
/* Select a parent that contains a specific child */ .card:has(img) { padding-top: 0; /* remove top padding when card has an image */ } /* Style a label when its input is focused */ label:has(+ input:focus) { color: #6366f1; } /* Highlight a form group with an invalid field */ .form-group:has(:invalid) { border-color: #ef4444; } /* Select an element that does NOT contain something */ .card:not(:has(img)) { background: #f0f0f0; /* text-only cards */ } /* Style body based on modal being open */ body:has(.modal.open) { overflow: hidden; } /* Style a grid differently based on child count */ .grid:has(> :nth-child(4)) { grid-template-columns: repeat(2, 1fr); /* 4+ items = 2 columns */ }
The parent container changes style when the checkbox is checked — powered by :has(input:checked). No JavaScript needed!
Cascade Layers (@layer)
Cascade layers give you explicit control over the cascade order, independent of specificity and source order. Layers declared first have lower priority.
/* 1. Declare layer order (low → high priority) */ @layer reset, base, components, utilities; /* 2. Add styles to layers */ @layer reset { *, *::before, *::after { box-sizing: border-box; margin: 0; } } @layer base { a { color: #6366f1; } h1 { font-size: 2rem; } } @layer components { .btn { padding: 0.5rem 1rem; background: #6366f1; color: white; } } @layer utilities { .text-center { text-align: center; } .hidden { display: none; } } /* Utilities layer wins over components, even with lower specificity, because it's declared later! */ /* Import a stylesheet into a layer */ @import url('vendor.css') layer(vendor);
This text is styled by @layer demo-override (green, bold)
Layer order: demo-reset (gray) < demo-base (pink) < demo-override (green). The last layer wins regardless of specificity.
Native CSS Nesting
CSS now supports nesting natively — no preprocessor required. Use & to reference the parent selector, just like Sass.
/* Native CSS nesting — works in all modern browsers */ .card { padding: 1.5rem; border-radius: 12px; background: var(--bg-surface); /* Nested element */ & .card-title { font-size: 1.25rem; font-weight: 600; } /* Pseudo-classes */ &:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } /* Pseudo-elements */ &::before { content: ""; display: block; } /* Media queries can nest too! */ @media (width >= 768px) { display: grid; grid-template-columns: 200px 1fr; } /* Modifier patterns */ &.card--featured { border-color: var(--color-primary); } } /* Deeply nested (but keep it shallow for readability!) */ .nav { & .nav-list { display: flex; & .nav-item { &:hover { color: var(--color-primary); } } } }
Subgrid
Subgrid allows a grid item's children to participate in the parent grid's track sizing. This solves the alignment problem where cards in a grid have different-height sections.
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; } .grid-item { display: grid; /* Inherit row tracks from parent grid */ grid-row: span 3; /* item spans 3 row tracks */ grid-template-rows: subgrid; /* children use parent's rows */ } /* Now all .grid-item children align perfectly across the grid, even if their content has different lengths! */ /* Works for columns too */ .wide-item { grid-column: span 2; grid-template-columns: subgrid; }
color-mix()
The color-mix() function blends two colors in a specified color space. Perfect for generating tints, shades, and transparent variations.
/* color-mix(in colorspace, color1 percentage, color2 percentage) */ /* Mix colors 50/50 */ background: color-mix(in srgb, #6366f1, #f472b6); /* Create a tint (mix with white) */ --primary-light: color-mix(in srgb, #6366f1 30%, white); /* Create a shade (mix with black) */ --primary-dark: color-mix(in srgb, #6366f1 70%, black); /* Create semi-transparent version */ --primary-50: color-mix(in srgb, #6366f1 50%, transparent); /* Use oklch for perceptually uniform mixing */ --blend: color-mix(in oklch, #6366f1, #34d399); /* Works with CSS variables! */ --hover-bg: color-mix(in srgb, var(--color-primary) 15%, transparent);
100%
80% + white
60% + white
40% + white
20% + white
mix pink
mix green
oklch mix
accent-color
The accent-color property themes native form controls (checkboxes, radio buttons, range sliders, progress bars) with a single line of CSS.
/* Theme all form controls globally */ :root { accent-color: #6366f1; } /* Or per-element */ .custom-checkbox { accent-color: #34d399; } .danger-checkbox { accent-color: #ef4444; }
New Viewport Units
The classic vh unit is unreliable on mobile because it doesn't account for browser chrome (address bar). New units solve this.
/* Classic (problematic on mobile) */ height: 100vh; /* includes space behind address bar on mobile */ /* Dynamic viewport height — changes as browser chrome shows/hides */ height: 100dvh; /* updates dynamically — RECOMMENDED for most cases */ /* Small viewport height — minimum visible area (address bar showing) */ height: 100svh; /* safe: never extends behind browser chrome */ /* Large viewport height — maximum visible area (address bar hidden) */ height: 100lvh; /* same as 100vh on desktop */ /* Width equivalents also exist: dvw, svw, lvw */ /* Block/inline equivalents: dvb, svb, lvb, dvi, svi, lvi */ /* Common pattern for mobile-safe full-height layouts */ .hero { min-height: 100svh; /* or 100dvh */ }
100dvh for hero sections and full-screen layouts. Use 100svh when you need a guaranteed minimum height that won't cause content to jump as the address bar animates.
Individual Transform Properties
Instead of a single transform with multiple functions, you can now use individual properties that can be animated independently.
/* Old way: single transform property */ .element { transform: translateX(50px) rotate(45deg) scale(1.2); } /* New way: individual properties */ .element { translate: 50px 0; rotate: 45deg; scale: 1.2; } /* The big advantage: animate them independently! */ .card { translate: 0 0; scale: 1; transition: translate 0.3s ease, scale 0.2s ease; } .card:hover { translate: 0 -4px; /* lifts up slowly */ scale: 1.02; /* grows quickly */ } /* No more overriding the entire transform chain */ .base { rotate: 10deg; } .base:hover { scale: 1.1; } /* rotation is preserved! */
@property — Typed Custom Properties
@property registers custom properties with a type, initial value, and inheritance. This enables animating custom properties — something impossible with regular -- variables.
/* Register a custom property with type information */ @property --gradient-angle { syntax: "<angle>"; initial-value: 0deg; inherits: false; } @property --color-start { syntax: "<color>"; initial-value: #6366f1; inherits: false; } /* Now you can ANIMATE the gradient! */ .gradient-box { background: linear-gradient( var(--gradient-angle), var(--color-start), #f472b6 ); transition: --gradient-angle 1s, --color-start 0.5s; } .gradient-box:hover { --gradient-angle: 180deg; --color-start: #34d399; } /* Supported syntax types: "<length>" "<number>" "<percentage>" "<color>" "<angle>" "<time>" "<integer>" "<length-percentage>" "<custom-ident>" "<image>" "*" (any) */
@starting-style
@starting-style defines styles for when an element first appears (entry animation). It enables CSS-only appear transitions for elements added to the DOM or changing from display: none.
/* Element's normal state */ .toast { opacity: 1; translate: 0 0; transition: opacity 0.3s, translate 0.3s; } /* State when element first appears */ @starting-style { .toast { opacity: 0; translate: 0 20px; } } /* Works with display transitions too! */ .dialog[open] { opacity: 1; scale: 1; transition: opacity 0.3s, scale 0.3s, display 0.3s allow-discrete; } @starting-style { .dialog[open] { opacity: 0; scale: 0.9; } }
text-wrap: balance
Automatically balances the number of characters per line in a text block so lines are roughly equal length. Perfect for headings.
/* Apply to headings for balanced line wrapping */ h1, h2, h3 { text-wrap: balance; /* balances lines evenly */ } /* For body text that should fill all available space */ p { text-wrap: pretty; /* avoids orphans on last line */ } /* Note: balance works best on short text (max ~6 lines). For longer text, the browser falls back to normal wrapping. */
A Long Heading That Wraps Awkwardly Across Lines
A Long Heading That Wraps Elegantly Across Lines
@supports — Feature Queries
Use @supports to conditionally apply CSS based on browser support. Essential for progressive enhancement.
/* Check if a property is supported */ @supports (container-type: inline-size) { .card-wrapper { container-type: inline-size; } } /* Check if NOT supported (fallback) */ @supports not (container-type: inline-size) { .card { /* media query fallback */ } } /* Check for a selector */ @supports selector(:has(*)) { /* use :has() safely */ } /* Combine with AND / OR */ @supports (display: grid) and (gap: 1rem) { /* grid with gap support */ } @supports (backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px)) { .glass { backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } }
Browser Support Table
Gotchas
container-type: inline-size on the parent. Without it, @container queries won't work. This also means the container's inline size can't depend on its children's size.
:has() selectors (especially with deep descendant checks) can be expensive. Browsers are optimizing rapidly, but keep selectors as simple as possible.
@layer has higher priority than all layered styles, regardless of layer order. This is by design but can be surprising.
.card { & .title {} } is equivalent to .card .title with specificity (0,0,2,0), not (0,0,1,0). Be aware of this when migrating from Sass.
syntax, initial-value, and inherits are all mandatory. Omitting any one of them will cause the rule to be ignored.
Pro Tips
.has-image via JS, use .card:has(img). Less JS, more declarative, automatically stays in sync.
@layer reset, base, layout, components, utilities to create a clear priority order. Third-party CSS should always go in its own low-priority layer.
/* Declare order from lowest to highest priority */ @layer reset, vendor, base, layout, components, utilities; /* Import third-party CSS into a low-priority layer */ @import url('normalize.css') layer(reset); @import url('library.css') layer(vendor); /* Utility classes always win (highest layer) */ @layer utilities { .hidden { display: none !important; } .sr-only { /* screen reader only */ } }
@property, you register the angle or color as typed custom properties and animate those instead.
@supports, with sensible fallbacks for older browsers. Most modern CSS features degrade gracefully.
h1, h2, h3, h4 { text-wrap: balance; } to your base styles.