How to Migrate from Media Queries to Container Queries in Tailwind CSS v4
- Audit your codebase for components that appear in multiple layout contexts (sidebars, modals, grids).
- Add the
@containerutility to each component's direct layout parent element. - Swap viewport prefixes for container prefixes —
md:becomes@md:,lg:becomes@lg:. - Adjust breakpoint sizes, since container breakpoints (
@md= 448px) are smaller than viewport breakpoints (md= 768px). - Name containers with
@container/cardsyntax when nesting requires targeting a specific ancestor. - Test each component at three parent widths (~250px, ~450px, full-width) using DevTools container query debugging.
- Keep viewport media queries for page-level layout decisions like grid columns and navigation visibility.
You build a responsive card component with md:flex-row and lg:gap-6, test it in a full-width grid, and everything looks perfect. Then a designer drops that same card into a 300px sidebar, and the layout crumbles. This is the fundamental flaw that Tailwind CSS v4 container queries fix: media queries respond to the viewport, not to the space a component actually occupies.
Tailwind CSS v4 ships native container query utilities that let components query their parent container's dimensions instead of the browser window. In this CSS container queries tutorial, I'll walk through the new syntax, migrate a real component from viewport breakpoints to container breakpoints, and lay out practical guidelines for when each approach still makes sense. Browser support? Container queries hit Baseline 2023. Chrome 105+, Firefox 110+, Safari 16+. You can ship this today.
Table of Contents
- The Problem with Media Queries in Component-Based Design
- How Container Queries Work (The 30-Second Primer)
- Tailwind CSS v4's Container Query Syntax
- Migration Walkthrough: From Media Queries to Container Queries
- When to Keep Media Queries
- Performance and Browser Support Notes
- Key Takeaways and Next Steps
The Problem with Media Queries in Component-Based Design
Why Viewport Breakpoints Break Components
Media queries evaluate the viewport's width. That's it. They know nothing about the <div> your component lives inside, the grid column constraining it, or the modal wrapping it. Fine for monolithic pages. Terrible for component-based UI development.
A single <ProductCard> might appear in a three-column product grid, a single-column sidebar, a comparison modal, and a dashboard panel, all on the same page, all at the same viewport width.
When you attach md:flex-row to that card, you're saying "switch to horizontal layout when the browser window hits 768px." But if the card sits inside a 280px sidebar on a 1440px monitor, the viewport is wide, the card's parent is narrow, and the horizontal layout fires when there's no room for it.
Developers have worked around this for years with ugly hacks: wrapper-specific override classes like sidebar-card, JavaScript-based ResizeObserver listeners that toggle classes imperatively, or outright duplicated component variants. Every one of these approaches is fragile, hard to maintain, and scales poorly.
Here's the pattern that causes pain:
Code Example 1: A viewport-based product card that breaks in narrow parents
<!-- This card uses viewport breakpoints -->
<div class="max-w-sm rounded-lg border bg-white shadow">
<img
src="/product.jpg"
alt="Product"
class="w-full rounded-t-lg md:w-48 md:rounded-l-lg md:rounded-t-none"
/>
<div class="p-4">
<h3 class="text-lg font-semibold md:text-xl">Wireless Headphones</h3>
<p class="mt-1 text-sm text-gray-600">Premium noise-canceling audio</p>
<span class="mt-2 inline-block text-lg font-bold text-indigo-600">$249</span>
</div>
</div>
<!--
Problem: At viewport >= 768px, md: utilities activate.
If this card is placed inside a 280px sidebar, the image
gets md:w-48 (192px), leaving only ~88px for text.
The layout is broken, but the viewport is "wide."
-->
Drop this into a full-width container and it looks great. Drop it into a sidebar and the md: utilities fire based on the browser window, not the available space. The image claims 192px of a 280px parent, and the text column collapses.
Media queries evaluate the viewport's width. That's it. They know nothing about the
<div>your component lives inside, the grid column constraining it, or the modal wrapping it. Fine for monolithic pages. Terrible for component-based UI development.
How Container Queries Work (The 30-Second Primer)
Containment Context and @container
Two parts to the mental model:
- A parent element declares itself as a containment context using
container-type. This tells the browser: "My descendants might want to know how wide I am." - A child element uses an
@containerrule to conditionally apply styles based on that parent's dimensions.
The most common declaration is container-type: inline-size, which enables queries against the container's inline axis (width in horizontal writing modes). You'll almost always want inline-size rather than size. Why? size also requires block-axis containment, which can mess with height calculations and cause unexpected layout behavior, particularly with auto-height content.
This is native CSS from the CSS Containment Module Level 3 spec. Tailwind didn't invent it. Tailwind v4 wraps these primitives in utility classes that feel natural alongside the sm:, md:, lg: responsive prefixes you already know.
Named vs Unnamed Containers
Containers can optionally be named. When a child uses @container (min-width: 400px), it queries the nearest ancestor container. But if you have nested containers and need to target a specific one, named containers solve that. A parent declares container-name: card, and a descendant queries it explicitly with @container card (min-width: 400px).
Tailwind v4 supports both patterns with clean syntax.
Tailwind CSS v4's Container Query Syntax
Declaring a Container with @container
To establish a containment context in Tailwind v4, add the @container utility to a parent element. This sets container-type: inline-size on that element, making it queryable by its descendants.
For named containers, use the slash syntax: @container/card. This sets both container-type: inline-size and container-name: card on the element.
Code Example 2: Container declarations
<!-- Unnamed container (queries from any descendant hit this) -->
<div class="@container">
<!-- children can use @sm:, @md:, etc. -->
</div>
<!-- Named container (descendants can target it specifically) -->
<div class="@container/card">
<!-- children can use @sm/card:, @md/card:, etc. -->
</div>
That's the entire parent-side setup. No custom CSS, no configuration file changes for the basic case.
Querying the Container with Size Variants
Once a parent is a container, child elements use container-query breakpoint prefixes: @sm:, @md:, @lg:, @xl:, and so on. These map to Tailwind's container query theme scale, which uses rem-based values. The defaults from Tailwind's documentation are:
| Prefix | Min-width |
|---|---|
@3xs | 16rem (256px) |
@2xs | 18rem (288px) |
@xs | 20rem (320px) |
@sm | 24rem (384px) |
@md | 28rem (448px) |
@lg | 32rem (512px) |
@xl | 36rem (576px) |
@2xl | 42rem (672px) |
@3xl | 48rem (768px) |
@4xl | 56rem (896px) |
@5xl | 64rem (1024px) |
@6xl | 72rem (1152px) |
@7xl | 80rem (1280px) |
Notice these are intentionally different from viewport breakpoints (sm = 640px, md = 768px). Container breakpoints are smaller because components typically live in narrower spaces than full viewports.
To target a named container, append the name: @md/card:flex-row means "when the container named card is at least 28rem wide, apply flex-row."
Now here's the payoff. Remember that broken product card from earlier? Here's the container-query version:
Code Example 3 (Viral Asset): Media queries vs container queries side-by-side
BEFORE: Viewport-based (breaks in narrow parents)
<div class="max-w-sm rounded-lg border bg-white shadow">
<img
src="/product.jpg"
alt="Product"
class="w-full rounded-t-lg md:w-48 md:rounded-l-lg md:rounded-t-none"
/>
<div class="p-4">
<h3 class="text-lg font-semibold md:text-xl">Wireless Headphones</h3>
<p class="mt-1 text-sm text-gray-600">Premium noise-canceling audio</p>
<span class="mt-2 inline-block text-lg font-bold text-indigo-600">$249</span>
</div>
</div>
AFTER: Container-based (adapts to any parent width)
<!-- Parent declares itself as a container -->
<div class="@container">
<div class="rounded-lg border bg-white shadow @md:flex">
<img
src="/product.jpg"
alt="Product"
class="w-full rounded-t-lg @md:w-48 @md:rounded-l-lg @md:rounded-t-none"
/>
<div class="p-4">
<h3 class="text-lg font-semibold @md:text-xl">Wireless Headphones</h3>
<p class="mt-1 text-sm text-gray-600">Premium noise-canceling audio</p>
<span class="mt-2 inline-block text-lg font-bold text-indigo-600">$249</span>
</div>
</div>
</div>
The media-query version breaks in a narrow sidebar. The container-query version adapts automatically. When the
@containerparent is wide enough (28rem+), the card switches to a horizontal layout. When the parent is narrow, regardless of the viewport width, the card stays stacked. The component finally owns its own responsive behavior.
Custom Container Query Breakpoints
If Tailwind's default container scale doesn't fit your component's needs, define custom breakpoints using the @theme directive in your CSS file (Tailwind v4's CSS-first configuration approach):
Code Example 4: Custom container breakpoint
@import "tailwindcss";
@theme {
--container-compact: 12rem; /* 192px - for very compact widgets */
--container-sidebar: 16rem; /* 256px - for sidebar cards */
}
<div class="@container">
<!-- Switches to horizontal at just 16rem (256px) parent width -->
<div class="flex flex-col @sidebar:flex-row">
<img src="/avatar.jpg" class="size-12 rounded-full" />
<p class="mt-2 @sidebar:ml-3 @sidebar:mt-0">Jane Smith</p>
</div>
</div>
Worth noting: @3xs and @2xs already exist in Tailwind v4's default container scale (at 16rem and 18rem respectively). The example above uses custom names (compact, sidebar) to avoid overriding built-in breakpoints, keeping the default scale intact while adding project-specific thresholds. This lets you tailor breakpoints to the specific thresholds where your component's layout needs to shift, rather than conforming to a global scale.
Migration Walkthrough: From Media Queries to Container Queries
Step 1: Identify Components, Not Pages
Don't try to migrate your entire codebase at once. Start by auditing for components that appear in multiple layout contexts. When I audited a dashboard project, I found that three components (a stats card, a user avatar row, and a notification panel) accounted for 80% of our responsive override hacks. Those were the migration candidates.
A quick heuristic: if a component has ever needed a wrapper-specific class like sidebar-stats-card or a conditional class toggled by a parent, it belongs on your container query migration list.
Step 2: Wrap Parents with @container
The container should be the direct layout parent that controls available width: a grid cell, a sidebar wrapper, a modal body, a dashboard panel. Place @container there.
A word of caution: don't slap @container on every <div> in your markup. Containment establishes a new formatting context on the element, and while the practical impact is usually invisible, it can affect absolutely positioned descendants and margin collapsing. More importantly, you must ensure the container element actually has a constrained inline size. If a container's width isn't bounded (say it's a full-width block with no max-width or grid/flex constraints), your container breakpoints will fire at the same thresholds regardless of context. That defeats the entire purpose.
Step 3: Swap Viewport Prefixes for Container Prefixes
The mechanical change is straightforward: md: becomes @md:, lg: becomes @lg:, and so on. But the pixel values are not 1:1. Viewport md is 768px. Container @md is 28rem (448px). You'll need to choose different container breakpoints than the viewport ones you were using. I've found that @sm (384px) and @md (448px) hit the sweet spot for most card-style components, while @lg (512px) works well for wider content blocks.
Here's a complete migration of a dashboard stats card:
Code Example 5: Full before/after migration
BEFORE: Viewport-based stats card
<!-- Stats card using viewport breakpoints -->
<div class="rounded-xl border bg-white p-4 shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<!-- Icon -->
<div class="mb-3 flex size-10 items-center justify-center rounded-lg bg-blue-100 sm:mb-0">
<svg class="size-5 text-blue-600"><!-- chart icon --></svg>
</div>
<!-- Content -->
<div class="sm:ml-4 sm:flex-1">
<p class="text-sm text-gray-500">Monthly Revenue</p>
<p class="text-2xl font-bold text-gray-900 lg:text-3xl">$48,290</p>
</div>
<!-- Trend badge -->
<span class="mt-2 inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700 sm:mt-0">
+12.5%
</span>
</div>
</div>
AFTER: Container-based stats card
<!-- Layout parent becomes the container -->
<div class="@container">
<div class="rounded-xl border bg-white p-4 shadow-sm">
<div class="flex flex-col @sm:flex-row @sm:items-center @sm:justify-between">
<!-- Icon -->
<div class="mb-3 flex size-10 items-center justify-center rounded-lg bg-blue-100 @sm:mb-0">
<svg class="size-5 text-blue-600"><!-- chart icon --></svg>
</div>
<!-- Content: uses @sm for layout shift, @lg for text scaling -->
<div class="@sm:ml-4 @sm:flex-1">
<p class="text-sm text-gray-500">Monthly Revenue</p>
<p class="text-2xl font-bold text-gray-900 @lg:text-3xl">$48,290</p>
</div>
<!-- Trend badge -->
<span class="mt-2 inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700 @sm:mt-0">
+12.5%
</span>
</div>
</div>
</div>
The changes are minimal: @container on the parent, sm: becomes @sm:, lg: becomes @lg:. But the behavioral difference is dramatic. This card now renders as a stacked vertical layout in a narrow sidebar and switches to horizontal in a wider panel, all without knowing anything about the viewport.
Step 4: Test in Multiple Contexts
After migrating, drop the component into at least three different parent widths. I typically test at roughly 250px (narrow sidebar), 450px (medium panel), and full-width. Chrome DevTools has excellent container query debugging: when you inspect an element with container query styles, DevTools highlights the container boundary and shows which container breakpoints are active. Firefox offers similar tooling in its layout panel.
One gotcha to watch for: nested containers. If you have a container inside a container, an @md: query on a deeply nested child will target the nearest ancestor container, not necessarily the one you expect. This is where named containers (@container/card and @md/card:) become essential. If your migration produces unexpected behavior, check whether an intermediate ancestor has inadvertently become a container.
When to Keep Media Queries
Viewport-Level Layout Decisions
Container queries don't replace media queries for everything. Media queries still own top-level page structure: switching from a multi-column layout to a single-column mobile stack, showing or hiding a navigation drawer, adjusting global font sizes, or changing the page's overall spacing rhythm. These are viewport-level decisions that have nothing to do with an individual component's parent.
The rule I use: page layout = media queries; component layout = container queries. If you're controlling how the page's major sections arrange themselves, reach for md: and lg:. If you're controlling how a reusable component adapts to its available space, reach for @md: and @lg:.
The rule I use: page layout = media queries; component layout = container queries. If you're controlling how the page's major sections arrange themselves, reach for
md:andlg:. If you're controlling how a reusable component adapts to its available space, reach for@md:and@lg:.
Combining Both Approaches
Tailwind v4 lets you mix viewport and container variants on the same page without conflict. They compile to different CSS conditional rules (@media vs @container) and coexist cleanly.
Code Example 6: Viewport + container utilities together
<!-- Page grid uses VIEWPORT breakpoints for overall layout -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<!-- Each grid cell is a CONTAINER for its child component -->
<div class="@container">
<div class="flex flex-col @md:flex-row @md:items-center rounded-lg border p-4">
<img src="/item.jpg" class="size-16 rounded-lg @md:mr-4" />
<div>
<h3 class="font-semibold">Product Title</h3>
<p class="text-sm text-gray-500">$99.00</p>
</div>
</div>
</div>
<!-- Repeat for other grid cells -->
</div>
Here, md:grid-cols-3 is a viewport query: when the browser window is at least 768px wide, the grid shows three columns. Inside each column, @md:flex-row is a container query: when that specific grid cell is at least 448px wide, the card layout goes horizontal. The page layout and the component layout respond to separate, appropriate triggers.
Performance and Browser Support Notes
Browser Compatibility
Container queries work in Chrome 105+, Firefox 110+, and Safari 16+. According to Can I Use, global support sits at approximately 93% of tracked users. For the vast majority of production applications targeting evergreen browsers, no polyfill is needed.
If you must support older browsers, Google Chrome Labs maintains a container-query-polyfill on GitHub. Be aware that polyfills for container queries have real limitations: they use ResizeObserver under the hood, which means a performance cost and potential layout flicker on complex pages. For most teams, the practical answer is to let the container-queried styles gracefully degrade to the mobile/default layout in unsupported browsers.
Rendering Performance
CSS containment is actually designed to help rendering performance. When you set container-type: inline-size, you tell the browser that this element's inline size doesn't depend on its descendants' content. The browser can isolate layout recalculations to that subtree rather than recalculating the entire document.
That said, this isn't a blanket performance win. Deeply nesting dozens of containment contexts adds complexity to the browser's layout tree. I've never seen this cause measurable issues in real-world applications with reasonable component hierarchies. But if you're building something like a deeply recursive tree view where every node is a container, profile before assuming containment is free. For typical component-based CSS architectures with a few layers of nesting, the performance impact ranges from negligible to slightly positive.
CSS containment is actually designed to help rendering performance. When you set
container-type: inline-size, you tell the browser that this element's inline size doesn't depend on its descendants' content. The browser can isolate layout recalculations to that subtree rather than recalculating the entire document.
Key Takeaways and Next Steps
Three ideas to carry away from this:
- Container queries let components own their responsive behavior regardless of where they sit in a layout. The component queries its parent's width, not the viewport, eliminating the mismatch that has plagued responsive design for over a decade.
- Tailwind CSS v4 makes adoption trivial. Add
@containerto a parent, swapmd:for@md:on children, done. Named containers (@container/nameand@md/name:) handle nested scenarios cleanly. Custom breakpoints take a few lines in an@themeblock. - Media queries aren't dead. They're scoped to page-level layout where they still belong. The winning pattern is viewport queries for page structure, container queries for component internals.
Here's your next move: pick one heavily reused component in your current project, something that shows up in at least two different layout contexts. Migrate it using the four steps above. Drop it into a sidebar. Watch it adapt. That single experiment will rewire how you think about responsive design.
For further reading, the Tailwind CSS container queries documentation is the definitive reference for utility syntax and defaults. The CSS Containment Module Level 3 spec covers the underlying platform feature in full technical detail. And Can I Use is the place to check current browser support numbers before shipping to production.


