Module 9: Real-World Usage
Modern CSS in Production: Container Queries, :has(), Nesting, Layers and Logical Properties
The newer features that replace JavaScript and hacks in real projects, how each works, and how to adopt them safely with feature queries and progressive enhancement.
CSS has changed a great deal in the last few years. Several features that used to need JavaScript, preprocessors or hacks are now built in and supported by every major browser. This lesson covers the ones that matter most in production, and a safe way to adopt anything new.
Container queries#
A media query asks about the viewport. A container query asks about the size of a component's container. That is what a reusable component needs, because the same card might live in a wide main column on one page and a narrow sidebar on another.
.card-wrap {
container-type: inline-size;
container-name: card;
}
.card { display: grid; gap: 1rem; }
@container card (min-width: 32rem) {
.card { grid-template-columns: 12rem 1fr; }
}
Wide column (700px) Narrow sidebar (260px)
┌────────────────────────┐ ┌────────────┐
│ ┌──────┐ Title │ │ ┌────────┐ │
│ │ img │ text text │ │ │ img │ │
│ └──────┘ text text │ │ └────────┘ │
└────────────────────────┘ │ Title │
@container min-width: 32rem │ text text │
matched → two columns └────────────┘
no match → stacked
How it works:
container-type: inline-sizemakes the element a size container for queries on its width.- The query applies to descendants of the container, never to the container itself. That is why the wrapper and the
.cardare separate elements. - It does not look at the viewport, so the component adapts wherever you place it.
Containers also unlock units like cqw (1% of the container width), useful for fluid type inside a component. There is a small catch: an element with container-type: inline-size no longer takes its width from its content, so give it an explicit width, or let a flex or grid parent size it.
Use media queries for page-level structure and container queries for components.
:has(), the parent selector#
:has() selects an element based on what it contains, or what follows it.
/* a card that contains an image gets a different layout */
.card:has(img) { grid-template-columns: 8rem 1fr; }
/* a form row where the input is invalid */
.field:has(input:user-invalid) label { color: #b42318; }
/* a section with no children at all */
.list:not(:has(li)) { display: none; }
/* style the previous sibling: a label before a checked box */
label:has(+ input:checked) { font-weight: 700; }
/* body styles when a modal is open */
body:has(dialog[open]) { overflow: hidden; }
It does what required JavaScript and extra classes before. The last example locks page scrolling when any dialog is open, with no script.
Keep it targeted. A selector like :has() placed on a very broad element, such as body:has(.something-deep), makes the browser do more matching work when the DOM changes. Use it on narrow scopes.
Native nesting#
.nav {
display: flex;
gap: 1rem;
a {
color: inherit;
&:hover { color: var(--brand); }
&[aria-current="page"] { font-weight: 700; }
}
@media (max-width: 640px) {
flex-direction: column;
}
}
It works much like Sass nesting, without a build step. Conditional rules such as @media can nest inside a selector, which keeps a component's responsive behavior in one place. The same specificity warning applies: deep nesting produces long selectors, so keep it to two or three levels.
Cascade layers in practice#
@layer reset, tokens, base, components, utilities;
@layer reset { *, *::before, *::after { box-sizing: border-box; margin: 0; } }
@layer base { body { font: 1rem/1.6 system-ui; } }
@layer components { .btn { padding: 0.6rem 1.2rem; } }
@layer utilities { .mt-4 { margin-top: 1rem; } }
The first line fixes the priority order for the whole project. Later layers win, no matter how specific the selectors are. A utility class always overrides a component, and a third-party library imported into a lower layer can never overwhelm your code:
@import url("library.css") layer(vendor);
Remember that unlayered styles beat all layers. Putting everything into layers, including your overrides, keeps the system consistent.
Logical properties#
Physical properties (left, right, margin-left) assume left-to-right text. Logical properties follow the writing direction, so layouts flip automatically for Arabic or Hebrew, and rotate for vertical scripts.
| Physical | Logical |
|---|---|
margin-left / margin-right | margin-inline-start / margin-inline-end |
margin-top / margin-bottom | margin-block-start / margin-block-end |
padding: 0 1rem | padding-inline: 1rem |
width / height | inline-size / block-size |
left: 0 | inset-inline-start: 0 |
text-align: left | text-align: start |
.alert { padding-inline: 1rem; border-inline-start: 4px solid var(--brand); }
An alert with a left accent bar in English gets the bar on the right in Arabic, with no extra rules. Even for English-only sites, logical properties cost nothing and make future localization simpler.
Other features worth knowing#
.thumb { aspect-ratio: 1; } /* box proportions */
.stack { display: grid; gap: 1rem; } /* gap works in flex and grid */
.title { text-wrap: balance; } /* even headline lines */
html { scroll-behavior: smooth; } /* wrap in prefers-reduced-motion check */
h2 { scroll-margin-top: 5rem; } /* anchor offset under a sticky header */
.btn { accent-color: var(--brand); } /* native control color */
.card { color: color-mix(in srgb, var(--brand) 30%, white); }
scroll-margin-top fixes the problem of anchor links that land hidden under a sticky header, a bug people used to solve with JavaScript.
Adopting new CSS safely#
- Check support. Look the feature up on MDN or caniuse. Many sites also display a "Baseline" label meaning it works in all major current browsers.
- Decide your audience. If analytics show a meaningful share of users on older browsers, choose features accordingly.
- Use progressive enhancement. Write a working layout with older features, then enhance it.
.gallery { display: flex; flex-wrap: wrap; gap: 1rem; } /* works everywhere */
@supports (display: grid) {
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}
}
@supports checks whether the browser understands a declaration, and only applies the block if so. Unsupported declarations are ignored anyway, so you only need @supports when the new code would change a working fallback in a way that makes the page worse if only partly supported.
Also remember the simple fallback from Module 1: write the old declaration first and the new one after.
.box { height: 100vh; height: 100dvh; }
Browsers that do not understand dvh ignore the second line and keep the first.
Common mistakes#
- Using media queries for components that move between containers.
- Putting
container-typeon the same element you want to restyle. - Broad
:has()selectors on huge subtrees. - Deep nesting in native CSS.
- Mixing layered and unlayered styles unintentionally.
- Using physical properties and rewriting them for right-to-left later.
- Adopting features without checking support or providing a fallback.
- Adding
@supportseverywhere when a simple fallback line would do.
Practice#
- Build a card that switches from stacked to side-by-side based on its container width, and place it in a narrow sidebar and a wide column to see it adapt.
- Lock page scrolling when a
dialogis open, using only CSS and:has(). - Convert a small stylesheet to use logical properties, then set
dir="rtl"onhtmland see what flips. - Choose one feature from this lesson, look up its support, write a graceful fallback, and test it by temporarily disabling the feature in DevTools or an older browser.