{"id":264,"date":"2026-05-22T05:01:42","date_gmt":"2026-05-22T05:01:42","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tailwind-v4-container-queries-i-stopped-faking-responsiveness\/"},"modified":"2026-05-22T05:01:42","modified_gmt":"2026-05-22T05:01:42","slug":"tailwind-v4-container-queries-i-stopped-faking-responsiveness","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tailwind-v4-container-queries-i-stopped-faking-responsiveness\/","title":{"rendered":"Tailwind v4 Container Queries: I Stopped Faking Responsiveness"},"content":{"rendered":"<p>Short version for the impatient: if you&rsquo;re on Tailwind v4 and you&rsquo;re still writing <code>md:flex-row<\/code> on a component that lives inside a sidebar, you&rsquo;re styling the wrong thing. Container queries fixed a class of bugs I&rsquo;d been working around for years. If you want to know why, read on.<\/p>\n<p>I rebuilt the same product card four times last year. Not because the design changed. Because the card kept ending up in places I hadn&rsquo;t planned for: a wide hero grid, a narrow sidebar list, a half-width modal. Every time, the breakpoints I&rsquo;d written for the viewport made the card look slightly broken in at least one of those places. I&rsquo;d add another modifier, ship it, and tell myself the next refactor would be cleaner.<\/p>\n<p>It wasn&rsquo;t, because I was solving the wrong problem. The viewport is not what&rsquo;s wrong with a card in a sidebar. The sidebar is what&rsquo;s wrong with a card in a sidebar. Container queries let you write CSS that knows about the actual container the component is sitting in, not the window the user happens to have open. Tailwind v4 makes the syntax pleasant enough that I now use them by default for anything I expect to compose into more than one layout.<\/p>\n<h2 id=\"what-media-queries-were-always-lying-to-me-about\">What media queries were always lying to me about<\/h2>\n<p>The original responsive design contract was: tell me how wide the viewport is and I&rsquo;ll tell you how big things should be. That worked when pages had one column and a header. It stopped working the moment we built design systems out of components that get reused at different sizes.<\/p>\n<p>Here&rsquo;s the kind of code I used to write in Tailwind v3:<\/p>\n<pre><code class=\"language-jsx\">\/\/ Card.jsx \u2014 Tailwind v3\nfunction ProductCard({ product }) {\n  return (\n    &lt;article className=&quot;flex flex-col gap-4 rounded-2xl border p-4 md:flex-row md:items-center md:gap-6 lg:p-6&quot;&gt;\n      &lt;img\n        src={product.image}\n        alt=&quot;&quot;\n        className=&quot;aspect-square w-full rounded-xl md:w-40 lg:w-48&quot;\n      \/&gt;\n      &lt;div className=&quot;flex flex-col gap-2 md:flex-1&quot;&gt;\n        &lt;h3 className=&quot;text-lg font-semibold md:text-xl&quot;&gt;{product.name}&lt;\/h3&gt;\n        &lt;p className=&quot;text-sm text-slate-600&quot;&gt;{product.summary}&lt;\/p&gt;\n        &lt;span className=&quot;text-base font-medium&quot;&gt;${product.price}&lt;\/span&gt;\n      &lt;\/div&gt;\n    &lt;\/article&gt;\n  );\n}\n<\/code><\/pre>\n<p>It looks fine. It even works fine on the page I built it for. The problem starts the day I drop the same card into a 320px-wide sidebar on a wide monitor. The viewport is over <code>md<\/code>, so the card goes horizontal, the image gets pinned at <code>w-40<\/code>, and the title squishes next to a thumbnail that takes up half the available space.<\/p>\n<p>The viewport is screaming &ldquo;you&rsquo;re on a desktop, do desktop things.&rdquo; The card has no idea it&rsquo;s stuck in a 320px box. I used to fix this with prop drilling: pass a <code>variant=\"compact\"<\/code> flag down so the card could override its own breakpoints. That works, sort of, until you&rsquo;re four levels deep and the variant flag is on a third of your components for no good reason.<\/p>\n<h2 id=\"what-tailwind-v4-actually-gives-you\">What Tailwind v4 actually gives you<\/h2>\n<p>Container queries shipped to all evergreen browsers a while back. The CSS itself looks like this:<\/p>\n<pre><code class=\"language-css\">.card-container {\n  container-type: inline-size;\n}\n\n@container (min-width: 480px) {\n  .card { display: flex; flex-direction: row; }\n}\n<\/code><\/pre>\n<p>Tailwind has had <code>@tailwindcss\/container-queries<\/code> as a plugin since v3.2 if you wanted them. The friction was real though: install plugin, configure it, then write <code>@md:flex-row<\/code> and remember to mark a parent as <code>@container<\/code>. People didn&rsquo;t reach for it casually.<\/p>\n<p>In v4 the plugin moved into core. The <a href=\"https:\/\/tailwindcss.com\/blog\/tailwindcss-v4\" rel=\"nofollow noopener\" target=\"_blank\">Tailwind v4 release notes<\/a> call it out as one of the built-in capabilities you get without a plugin. The full container query reference lives in the <a href=\"https:\/\/tailwindcss.com\/docs\/responsive-design#container-queries\" rel=\"nofollow noopener\" target=\"_blank\">responsive design docs<\/a>, including the <code>@container<\/code> utility and the <code>@sm<\/code>, <code>@md<\/code>, <code>@lg<\/code> size variants that match container width instead of viewport width.<\/p>\n<p>Here&rsquo;s the same card rewritten so it cares about its own container:<\/p>\n<pre><code class=\"language-jsx\">\/\/ Card.jsx \u2014 Tailwind v4\nfunction ProductCard({ product }) {\n  return (\n    &lt;article className=&quot;@container&quot;&gt;\n      &lt;div className=&quot;flex flex-col gap-4 rounded-2xl border p-4 @md:flex-row @md:items-center @md:gap-6 @lg:p-6&quot;&gt;\n        &lt;img\n          src={product.image}\n          alt=&quot;&quot;\n          className=&quot;aspect-square w-full rounded-xl @md:w-40 @lg:w-48&quot;\n        \/&gt;\n        &lt;div className=&quot;flex flex-col gap-2 @md:flex-1&quot;&gt;\n          &lt;h3 className=&quot;text-lg font-semibold @md:text-xl&quot;&gt;{product.name}&lt;\/h3&gt;\n          &lt;p className=&quot;text-sm text-slate-600&quot;&gt;{product.summary}&lt;\/p&gt;\n          &lt;span className=&quot;text-base font-medium&quot;&gt;${product.price}&lt;\/span&gt;\n        &lt;\/div&gt;\n      &lt;\/div&gt;\n    &lt;\/article&gt;\n  );\n}\n<\/code><\/pre>\n<p>Two changes. The outer wrapper is marked <code>@container<\/code>. Every breakpoint prefix went from <code>md:<\/code> to <code>@md:<\/code>. That&rsquo;s it. Now the card asks &ldquo;am I wider than <code>@md<\/code>?&rdquo; (28rem by default, around 448px) and lays itself out accordingly. Drop it in a 320px sidebar, it stays vertical. Drop it in a 1200px hero, it goes horizontal. The page doesn&rsquo;t have to tell it which one.<\/p>\n<p>If you&rsquo;re new to Tailwind v4 in general, I wrote about my <a href=\"https:\/\/abrarqasim.com\/blog\/migrating-to-tailwind-v4-what-broke-what-i-wish-id-known\" rel=\"noopener\">migration from v3 and what broke along the way<\/a>. Worth reading first if you haven&rsquo;t moved yet.<\/p>\n<h2 id=\"a-real-example-the-search-result-that-finally-laid-itself-out-right\">A real example: the search result that finally laid itself out right<\/h2>\n<p>The clearest win I had with this was on a search results page. Each result was a card with a thumbnail, title, snippet, and a row of metadata. The page itself had two layouts:<\/p>\n<ol>\n<li>Full width on the results page itself.<\/li>\n<li>A 360px &ldquo;recent searches&rdquo; panel in the dashboard sidebar.<\/li>\n<\/ol>\n<p>With viewport-based breakpoints, the card was either right on the results page and wrong in the sidebar, or right in the sidebar and weirdly narrow on a 27-inch monitor. The fix used to be a Boolean prop <code>compact<\/code> that flipped the layout. Maintaining two parallel sets of class names on the same component is the kind of thing you regret six months later when one of them drifts.<\/p>\n<p>After the rewrite:<\/p>\n<pre><code class=\"language-jsx\">function SearchResult({ result }) {\n  return (\n    &lt;article className=&quot;@container border-b py-4&quot;&gt;\n      &lt;div className=&quot;flex flex-col gap-3 @md:flex-row @md:gap-4&quot;&gt;\n        &lt;img\n          src={result.thumbnail}\n          className=&quot;aspect-video w-full rounded @md:w-32 @md:flex-shrink-0&quot;\n          alt=&quot;&quot;\n        \/&gt;\n        &lt;div className=&quot;flex flex-col gap-1 @md:flex-1&quot;&gt;\n          &lt;a href={result.url} className=&quot;font-medium hover:underline&quot;&gt;\n            {result.title}\n          &lt;\/a&gt;\n          &lt;p className=&quot;text-sm text-slate-600 line-clamp-2&quot;&gt;{result.snippet}&lt;\/p&gt;\n          &lt;div className=&quot;flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-500&quot;&gt;\n            &lt;span&gt;{result.author}&lt;\/span&gt;\n            &lt;span&gt;{result.date}&lt;\/span&gt;\n            &lt;span className=&quot;hidden @sm:inline&quot;&gt;{result.readingTime} min read&lt;\/span&gt;\n          &lt;\/div&gt;\n        &lt;\/div&gt;\n      &lt;\/div&gt;\n    &lt;\/article&gt;\n  );\n}\n<\/code><\/pre>\n<p>The <code>compact<\/code> prop is gone. The <code>hidden @sm:inline<\/code> on the reading time means the metadata row drops the least important field when there isn&rsquo;t room. Notice that <code>@sm<\/code> here means &ldquo;the container is at least <code>sm<\/code>&rdquo;, not &ldquo;the viewport is <code>sm<\/code>&rdquo;. For a 360px sidebar, that&rsquo;s false. For a full-width results page, it&rsquo;s true. Same component, two different behaviors, zero prop drilling.<\/p>\n<p>The same pattern works for tables that become cards on narrow containers, for navs that collapse based on the sidebar width rather than the window width, and for media grids that re-pack themselves when the container resizes. It&rsquo;s not magic. It&rsquo;s just CSS finally being honest about what it should have been measuring all along.<\/p>\n<h2 id=\"where-i-still-dont-use-them\">Where I still don&rsquo;t use them<\/h2>\n<p>Container queries are not a free upgrade. There are places I deliberately keep using viewport breakpoints:<\/p>\n<ul>\n<li><strong>Top-level page chrome.<\/strong> The site header, the main grid, the footer. These care about the viewport because they are the viewport. Container queries would be a weird abstraction here.<\/li>\n<li><strong>Type scaling.<\/strong> I still bump up <code>text-base<\/code> to <code>text-lg<\/code> on bigger screens with <code>lg:<\/code> prefixes. Body copy size should track the reader&rsquo;s screen, not the column it&rsquo;s in. A reader on a phone wants smaller body type even if the article column happens to be 600px wide.<\/li>\n<li><strong>Modal sizing and full-bleed media.<\/strong> When something is supposed to feel &ldquo;big on big screens&rdquo;, viewport is the right axis.<\/li>\n<li><strong>Anything where the container is always the same size.<\/strong> No point paying for container query machinery on a component that only ever lives in one shape.<\/li>\n<\/ul>\n<p>A useful rule I&rsquo;ve settled on: use container queries when a component will plausibly live in two or more layout slots. Use viewport queries when the thing you&rsquo;re styling effectively <em>is<\/em> a layout slot.<\/p>\n<h2 id=\"performance-and-browser-support-briefly\">Performance and browser support, briefly<\/h2>\n<p>Container queries are supported in every evergreen browser. <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/CSS_containment\/Container_queries\" rel=\"nofollow noopener\" target=\"_blank\">Browser support per MDN<\/a> is universal in current Chrome, Edge, Firefox, and Safari. If you have to support older Safari or older Samsung Internet, check Can I Use for your specific traffic; for most product teams in 2026, the answer is &ldquo;ship it, move on.&rdquo;<\/p>\n<p>There&rsquo;s a small performance cost: the browser has to track the size of any element you mark as <code>container-type: inline-size<\/code>. I haven&rsquo;t seen this matter on real apps. If you&rsquo;re nesting <code>@container<\/code> ten levels deep on every list item in a long virtualized list, profile before assuming it&rsquo;s fine. Otherwise it&rsquo;s lost in the noise.<\/p>\n<p>One gotcha that bit me: <code>container-type: inline-size<\/code> implicitly makes the element a containment context for its children, which changes how <code>position: fixed<\/code> resolves inside it. If you have a tooltip or popover anchored inside a card that you&rsquo;ve marked as <code>@container<\/code>, you may find the popover anchoring weirder than expected. Move the popover out of the container, or use the Popover API, or anchor it to <code>document.body<\/code>. I lost an afternoon on this once.<\/p>\n<h2 id=\"what-you-can-do-this-week\">What you can do this week<\/h2>\n<p>Pick one component in your app that you&rsquo;ve written <code>md:<\/code> or <code>lg:<\/code> prefixes on, and that lives in more than one layout slot. Wrap it in <code>@container<\/code> and rename the prefixes to <code>@md:<\/code>, <code>@lg:<\/code>. Look at it in two different contexts. If it lays out correctly in both without any prop changes, you&rsquo;re done. If something breaks, you&rsquo;ve found a real problem that was already there, just hidden by the page happening to be wide enough.<\/p>\n<p>If you&rsquo;ve been putting off the bigger move, the <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-config-v4-six-months-without-config-js\" rel=\"noopener\">v4 config story is much less painful than I expected<\/a>. Container queries are the feature I&rsquo;d point to as the &ldquo;why bother&rdquo;. The rest is a quality-of-life upgrade.<\/p>\n<p>I write about this kind of frontend day-to-day stuff in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my recent work and case studies<\/a> when I get the chance. Happy to compare notes if you&rsquo;ve taken your design system through the same transition.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Tailwind v4 ships container queries in core. Here&#8217;s the rewrite that stopped my product cards from breaking in sidebars, and where I still avoid them.<\/p>\n","protected":false},"author":2,"featured_media":263,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Tailwind v4 ships container queries in core. Here's the rewrite that stopped my product cards from breaking in sidebars, and where I still avoid them.","rank_math_focus_keyword":"tailwind v4","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,35],"tags":[141,37,38,140,139,36,134],"class_list":["post-264","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-web-development","tag-container-queries","tag-css","tag-frontend","tag-responsive-design","tag-tailwind","tag-tailwind-css","tag-tailwind-v4"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/264","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=264"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/264\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/263"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=264"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=264"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=264"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}