Modern CSS
CSS Has Changed
The CSS you learned in 2015 is not the CSS of today. Native nesting, container queries, the :has() selector, cascade layers, and powerful math functions have arrived in browsers. Many patterns that once required preprocessors or JavaScript are now built into the platform.
This topic covers the modern features that change how you write CSS.
Custom Properties (Variables)
CSS custom properties are variables that cascade, inherit, and can be updated at runtime -- something preprocessor variables cannot do.
:root {
--color-primary: #3b82f6;
--color-text: #1a1a2e;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--radius: 6px;
}
.button {
background: var(--color-primary);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
}
Scoped Variables
Custom properties cascade, so you can override them at any level:
.card {
--card-padding: 16px;
padding: var(--card-padding);
}
.card.compact {
--card-padding: 8px;
/* padding automatically uses the new value */
}
Fallback Values
.element {
color: var(--color-accent, #e63946); /* Uses #e63946 if --color-accent is not defined */
}
Runtime Updates with JavaScript
// Change a variable for the entire page
document.documentElement.style.setProperty('--color-primary', '#e63946');
// Change a variable for a specific element
element.style.setProperty('--card-padding', '24px');
This is impossible with Sass variables. Custom properties exist in the browser, not just at build time.
CSS Math Functions
calc()
Combine different units in a single expression:
.sidebar {
width: calc(100% - 250px); /* Full width minus fixed sidebar */
}
.container {
padding: calc(var(--spacing-md) * 2);
}
clamp()
Sets a value with a minimum, preferred, and maximum:
.container {
width: clamp(320px, 90%, 1200px);
/* At least 320px, prefer 90% of parent, at most 1200px */
}
/* Fluid typography (no media queries needed) */
h1 {
font-size: clamp(1.5rem, 4vw, 3rem);
/* Minimum 1.5rem, scales with viewport, maximum 3rem */
}
min() & max()
.element {
width: min(100%, 600px); /* Whichever is smaller */
padding: max(16px, 2vw); /* Whichever is larger */
}
These functions eliminate many media queries. Instead of breakpoint-based sizing, you define a range and let the browser figure it out.
Container Queries
Media queries respond to the viewport size. Container queries respond to the parent container size. This is a fundamental shift for component-based design.
/* Define a containment context */
.card-container {
container-type: inline-size;
container-name: card;
}
/* Style based on the container's width, not the viewport */
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 150px 1fr;
gap: 16px;
}
}
@container card (max-width: 399px) {
.card {
display: flex;
flex-direction: column;
}
.card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
A card component can now adapt to its container, not the viewport. The same component works in a sidebar (narrow), a main content area (medium), or a full-width hero (wide) without any changes to the component itself.
Container query units (cqi, cqb) let you size elements relative to the container, similar to viewport units but scoped to the container.
The :has() Selector
Often called the "parent selector," :has() selects an element based on what it contains. CSS could never do this before.
/* Style a card differently when it contains an image */
.card:has(img) {
grid-template-columns: 200px 1fr;
}
.card:not(:has(img)) {
grid-template-columns: 1fr;
}
/* Style a form group when its input is invalid */
.form-group:has(input:invalid) {
border-color: #ef4444;
}
/* Style a label when its sibling checkbox is checked */
label:has(+ input:checked) {
font-weight: bold;
}
/* Enable a submit button when the form is valid */
form:has(:invalid) .submit-button {
opacity: 0.5;
pointer-events: none;
}
:has() reduces the need for JavaScript to toggle class names based on child state.
Native CSS Nesting
CSS nesting is now built into the language. No preprocessor required.
.card {
padding: 16px;
border-radius: 8px;
background: white;
.card-title {
font-size: 1.25rem;
margin-bottom: 8px;
}
.card-body {
color: #555;
line-height: 1.6;
}
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
@media (min-width: 768px) {
padding: 24px;
}
}
The & represents the parent selector, just like in Sass. For element selectors, you can nest directly. Media queries can be nested inside rules.
The & represents the parent selector, required for element selectors (& p {}) and pseudo-classes (&:hover {}). Class selectors can be nested directly (.child {}).
Cascade Layers (@layer)
Layers give you explicit control over the cascade order, regardless of specificity or source order.
/* Define layer order (first = lowest priority) */
@layer reset, base, components, utilities;
@layer reset {
* { margin: 0; padding: 0; box-sizing: border-box; }
}
@layer base {
body { font-family: system-ui; line-height: 1.6; }
a { color: var(--color-primary); }
}
@layer components {
.button { padding: 8px 16px; border-radius: 4px; }
.card { padding: 16px; background: white; }
}
@layer utilities {
.hidden { display: none; }
.text-center { text-align: center; }
}
Styles in later layers always win over styles in earlier layers, regardless of specificity. A utility class in the utilities layer beats a component style in the components layer, even if the component selector is more specific.
This solves the problem of utility classes being overridden by component styles (a constant pain with CSS frameworks).
Logical Properties
Logical properties replace physical properties (left, right, top, bottom) with flow-relative ones. They work correctly for all writing modes and text directions.
/* Physical (only correct for LTR, horizontal) */
.old-way {
margin-left: 16px;
padding-top: 8px;
border-right: 1px solid #ccc;
}
/* Logical (works for any writing mode or direction) */
.new-way {
margin-inline-start: 16px;
padding-block-start: 8px;
border-inline-end: 1px solid #ccc;
}
| Physical | Logical | Meaning |
|---|---|---|
left / right |
inline-start / inline-end |
Along the text direction |
top / bottom |
block-start / block-end |
Along the stacking direction |
width |
inline-size |
Size along text direction |
height |
block-size |
Size along stacking direction |
Shorthand margin-inline, margin-block, padding-inline, and padding-block replace directional pairs. Use logical properties for RTL support and future-proof CSS.
Other notable additions: aspect-ratio for intrinsic ratios (aspect-ratio: 16 / 9), accent-color for styling form controls, and color-mix() for blending colors in CSS.
Common Pitfalls
- Not using custom properties for theming: Hardcoding colors and spacing throughout your CSS makes changes painful. Define a design token system with custom properties.
- Overusing nesting: Deep nesting creates high-specificity selectors that are hard to override. Keep nesting to 2-3 levels.
- Ignoring container queries for components: If you are writing media queries inside a reusable component, you probably want container queries instead.
- Forgetting fallbacks for older browsers: Check browser support on caniuse.com. Features like
:has()and container queries are widely supported now but may not be available in older browser versions your users need. - Not defining
@layerorder upfront: Without an explicit order declaration, layers are ordered by first appearance. Declare all layers at the top of your stylesheet. - Using
calc()whereclamp()is cleaner:clamp(min, preferred, max)replaces manycalc()+ media query combinations.
Key Takeaways
- Custom properties cascade, inherit, and can be updated at runtime -- use them for design tokens
clamp(),min(), andmax()reduce the need for media queries- Container queries make components respond to their container, not the viewport
:has()selects elements based on their contents -- the missing piece CSS always needed- Native nesting eliminates the need for Sass/Less in many projects
- Cascade layers (
@layer) give you explicit control over the cascade - Logical properties (
inline-start,block-end) work correctly for all writing modes - Modern CSS is powerful enough that preprocessors are often unnecessary