Refactoring a real project to container queries
I stopped reading hot takes about container queries and actually refactored a real client project. A mid-sized marketing site with a component library, a CMS, and the usual pile of legacy CSS.
The goal was simple. Use container queries wherever component-level decisions made more sense than viewport-level ones. Keep media queries where they still pull their weight. No heroic rewrites. No greenfield fantasies.
This is not a spec tour. This is what I actually changed, what hurt, what felt great, and why I did not burn my media queries to the ground.
The old setup: media-query soup
The project started life the way a lot of responsive sites did. Global breakpoints like this:
:root {
--bp-xs: 480px;
--bp-sm: 640px;
--bp-md: 768px;
--bp-lg: 1024px;
--bp-xl: 1280px;
}
@media (min-width: 768px) { /* ... */ }
@media (min-width: 1024px) { /* ... */ }
Most components were basically: "If viewport is at least md, do layout X, otherwise stack Y". Classic.
The problem showed up when the same component lived in completely different layouts. Hero section inside a full-width page. Same hero in a sidebar-heavy layout. Same hero inside a CMS block inside a card.
Viewport-based breakpoints started to feel wrong. The component did not care about the viewport. It cared about its box.
Before touching CSS: I had to fix the HTML
Container queries need containers. Obvious, but it changes how you structure your HTML.
I did a quick audit. The rule I used was:
- If a component needs to respond to its own width, it gets a container.
- If a part inside a component needs to respond to the component, the parent becomes the container.
That turned into a few standard patterns.
Pattern 1: Layout shells as containers
Grid sections, sidebars, cards. Anything that wrapped other components and controlled width. Those became container roots.
<section class="page-section" data-layout="two-column">
<div class="page-section__content">
<article class="feature-card">...</article>
<article class="feature-card">...</article>
</div>
</section>
And the CSS:
.page-section__content {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
container-type: inline-size;
container-name: page-section;
}
Now the children could ask: "How wide is my section wrapper?" instead of "How wide is the viewport?".
Pattern 2: Cards and blocks as containers
Any reusable block in the design system got its own container. Things like .feature-card, .stat-block, .media-object.
.feature-card {
container-type: inline-size;
container-name: feature-card;
}
I did not add containers everywhere. Only where I had previously written a media query for "small card layout" vs "large card layout".
The first win: components stopped arguing with the viewport
The hero card was the first thing I switched. Previously it looked like this:
.hero-card {
display: grid;
gap: 1.5rem;
}
@media (min-width: 768px) {
.hero-card {
grid-template-columns: 3fr 2fr;
}
}
The problem. When the hero card lived in a skinny sidebar at 1440px viewport width, it still tried to go two-column. It just looked broken.
With container queries, the hero only cares about its own box.
.hero-card {
display: grid;
gap: 1.5rem;
container-type: inline-size;
container-name: hero-card;
}
@container hero-card (min-width: 560px) {
.hero-card {
grid-template-columns: 3fr 2fr;
}
}
Now the hero goes two-column only if the card itself is at least 560px wide. Not if the viewport happens to be big. That felt like how it should always have worked.
Where container queries really shine
After a week of refactoring, a few patterns stood out as massive improvements.
1. Components reused in unpredictable layouts
The design system had "content blocks" that editors could drop anywhere in the CMS. You can imagine the chaos.
- Inside full-width sections
- Inside narrow sidebars
- Inside cards that lived in carousels
In the old version I cheated. I limited where editors could place certain components. I basically told the CMS what the layout was allowed to be, just to protect my media queries.
With container queries that restriction went away. Each block simply responded to the space it actually received.
.content-block {
container-type: inline-size;
container-name: content-block;
}
@container content-block (min-width: 700px) {
.content-block--image-right {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
}
The same content block behaved nicely in a 900px region and inside a 420px sidebar. No new breakpoint. No layout-specific modifier. Just local logic.
2. Nested complexity without breakpoint explosions
One section had a nasty nesting problem. Three cards across on desktop. Cards had stats, icons, buttons, and sometimes a badge. Each card also changed layout internally at md.
With media queries I had two sets of breakpoints. One set for the section grid. One set for the cards. They needed to stay in sync. You know how this ends.
Container queries let the section and cards negotiate separately.
.stats-section {
display: grid;
gap: 1.5rem;
container-type: inline-size;
container-name: stats-section;
}
@container stats-section (min-width: 900px) {
.stats-section {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.stat-card {
container-type: inline-size;
container-name: stat-card;
}
@container stat-card (min-width: 340px) {
.stat-card__body {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
}
}
The section manages its columns. The card manages its internal layout. No cross-dependency. No mental math about "what does 340px inside a 900px grid mean at 1024px viewport".
3. Dark corners of the design system became predictable
Every design system has a few awkward components. For me it was a "media object" pattern that designers used everywhere. Image + content + actions. Sometimes horizontal, sometimes vertical, sometimes tiny.
Previously I had a mess of modifiers: .media--compact, .media--horizontal-lg-only, all wired to global breakpoints. Maintaining them sucked.
Switching to container queries let me cut that down to "respond when too small or big".
.media-object {
display: flex;
flex-direction: column;
gap: 0.75rem;
container-type: inline-size;
container-name: media-object;
}
@container media-object (min-width: 520px) {
.media-object {
flex-direction: row;
align-items: flex-start;
}
}
Editors could now throw this pattern wherever they wanted. I did not need to ship ten variants. That felt like an actual productivity gain, not a theoretical nice-to-have.
Where I intentionally kept media queries
I am not treating container queries like a new religion. Viewport media queries still win in a few places and I kept them.
1. Global layout shifts
Some layout changes are tied to the viewport, not a container. Header navigation is a good example.
The site went from mobile nav to horizontal nav at around 900px viewport width. This is not a per-component decision. This is a global "how do we use the top 80px of the screen" decision.
@media (min-width: 900px) {
.site-header {
grid-template-columns: auto 1fr auto;
}
.nav-toggle {
display: none;
}
.primary-nav {
display: flex;
}
}
I could have wrapped the whole page in a container and used container queries there. I did not bother. Viewport width is the right source of truth for this.
2. Typography scale and rhythm
Global type scale based on viewport still makes sense to me. Things like base font size, vertical rhythm, and heading scales.
Container queries inside components are great for "if my card is cramped, reduce the heading size a bit". But the general "phone vs desktop reading experience" feels like a viewport choice.
html {
font-size: 15px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
@media (min-width: 1200px) {
html {
font-size: 17px;
}
}
I experimented with wrapping the main content area in a container and tying typography to that. It felt fussy and added complexity without real benefit. Viewport queries stayed.
3. "Hard" layout breakpoints in marketing pages
There were a few full-bleed marketing sections that changed layout aggressively. Big storytelling panels.
Those were designed around fixed viewport breakpoints in Figma. Trying to force them into container logic gave me weird edge cases. Things broke in exactly the places the designer cared about.
So I did not fight it. I kept simple viewport media queries:
@media (min-width: 1024px) {
.story-section {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
}
}
Sometimes the boring answer is the right one.
What actually changed in my workflow
Once the refactor settled, a few habits changed for good.
1. I think in "component width" instead of "screen size"
My first question used to be: "What happens at 768px and 1024px?". Now it is: "At what width does this component start to look stupid?".
That leads to smaller, more honest breakpoints.
@container card (min-width: 420px) { ... }
@container card (min-width: 640px) { ... }
I stopped pretending all components share the same breakpoints. They do not. And that is fine.
2. My CSS files are more local and less global
The old codebase had big global breakpoint sections. All components shared them. That sounds organized. In practice it meant every change risked side effects.
With container queries, most responsive logic lives next to the component styles.
.feature-card { ... }
@container feature-card (min-width: 480px) { ... }
@container feature-card (min-width: 720px) { ... }
I like this a lot better. When I open a component file I see all of its behaviour, including responsiveness, in one place.
3. Fewer "support grid X only" rules in the CMS
The CMS previously had hidden layout rules. We told editors: "You cannot put block Y inside layout X because it breaks at tablet."
That sort of rule is a smell. It tells you the CSS is too coupled to a specific layout. Container queries finally gave me a way to fix that instead of just documenting around it.
After the refactor, we removed several "you may not combine these" notes. Editors have more freedom. I get fewer layout bug tickets. That is an easy win.
Gotchas that actually hurt
It was not all magic. A few things were annoying enough that I would warn future-me before doing this again.
1. You must be deliberate about container boundaries
Early on I just sprinkled container-type: inline-size; everywhere. That was a mistake.
Too many nested containers make it hard to reason about which container is actually being queried.
.card {
container-type: inline-size;
container-name: card;
}
.card__content {
container-type: inline-size;
container-name: card-content;
}
Then inside CSS I wrote @container (min-width: 500px) without a name. Suddenly some rules were responding to card, others to card__content.
I fixed it by:
- Using named containers for anything complex.
- Limiting containers to a few well-known layers: page, section, component.
2. DevTools muscle memory is wrong
Checking responsive behaviour with container queries feels different. You no longer just drag the viewport. You need to also think about how the container width changes when the layout changes.
Modern DevTools help, but they are not perfect yet. Chrome and Firefox both have container query overlays, which helps a lot. But I still occasionally misdiagnose a bug as "container query not firing" when it is actually "different container is active".
3. Performance paranoia
Specs and browser teams have done a good job making container queries efficient. But old instincts kick in when you sprinkle dozens of them into a complex page.
I stress tested a few templates. So far, normal marketing-site scale has been fine. But I would not blindly apply container queries to every tiny utility component in a mega-dashboard without measuring.
How I would approach the next project
If I started a fresh project tomorrow, I would not go "container queries only". I would set some rules upfront.
- Use viewport media queries for global layout, navigation, and base typography.
- Use container queries for reusable components and CMS blocks that live in variable layouts.
- Define a small set of standard container layers:
page-shell,section,component. - Keep container names explicit in CSS, avoid unnamed queries except for trivial cases.
The main mental shift is this.
Media queries answer: "What does the world look like?". Container queries answer: "What does my box look like?".
You need both. Trying to force everything into one or the other is dogma, not engineering.
So, was the refactor worth it?
For this project, yes. Strong yes.
The biggest win was not fewer lines of CSS. It was fewer layout-specific hacks in the CMS and fewer weird "component behaves badly in this one layout" bugs.
Container queries made the system more honest. Components now respond to the thing they actually depend on: their own size. Media queries still run the global show.
If you have a design system that shows up in many layouts, or a CMS where editors can shuffle blocks freely, then I think you are leaving real value on the table by not using container queries.
If your app is mostly a couple of fixed layouts with hand-tuned breakpoints, then viewport media queries are still perfectly fine. I would not refactor just for the buzzword.
Use container queries where the component cares about its own box. Use media queries where the design cares about the screen. Once you draw that line, the rest becomes straightforward.
Subscribe to my newsletter to get the latest updates and news
Member discussion