Skip to content

Tailwind Container Queries in v4: The Sidebar Bug I Fixed

Tailwind Container Queries in v4: The Sidebar Bug I Fixed

Confession: I shipped a card component last month that looked perfect on my laptop and completely fell apart the moment someone dropped it into a narrow sidebar. Two columns squished into an unreadable mess, because I’d written the responsive rules against the viewport, and the viewport had no idea my card was now 280 pixels wide. I knew container queries existed. I’d just never bothered wiring them up in Tailwind. Then v4 made them a built-in, and my excuse evaporated.

The bug that media queries can’t see

Media queries answer one question: how wide is the browser window? That’s the wrong question when your component gets reused in different-sized slots. A product card in a full-width grid and the same card in a 300px sidebar live in the same viewport, so md: fires for both, and one of them looks broken.

Here’s the old Tailwind, keyed to the screen:

<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  <img class="w-full" src="/thumb.jpg" alt="">
  <div>
    <h3 class="text-base md:text-lg">Product name</h3>
    <p class="text-sm">Short description goes here.</p>
  </div>
</div>

The md:grid-cols-2 triggers at 768px of viewport width. Put this card in a skinny column on a wide monitor and it still goes two-up, because the window is wide even though the card isn’t. I used to patch this with a second set of classes and a prop that told the component which context it was in. It worked, and it was miserable to maintain.

The reason it’s miserable is that the component stops being reusable. Every time I placed the card somewhere new, I had to think about how wide that slot was and pass the right variant down. That’s exactly the coupling a component library is supposed to remove. A card should know how to lay itself out at any width and not care one bit about the page around it. Media queries can’t give you that, because they only ever measure one thing, and it’s the wrong thing for a nested component.

What container queries actually change

A container query asks a different question: how wide is my parent, not the window? You mark an element as a container, then style its children based on that element’s size. This shipped as a CSS standard a while back, and MDN has a solid rundown in its container queries guide. The mechanism is container-type: inline-size on the parent, and @container rules on the children.

In Tailwind v3 you needed the @tailwindcss/container-queries plugin for this. In v4 it’s part of core, no plugin, no config entry. You mark the parent with @container and use @-prefixed variants like @sm: and @md: on the children. Those variants respond to the container’s width instead of the screen’s.

The naming is the nice part. Once you know md: means “screen at least this wide,” @md: reads as “container at least this wide,” and the whole system clicks without new mental overhead. The container breakpoints have their own scale, starting small at @xs and climbing up, and they’re independent from the screen breakpoints, so you can tune a component’s internal breakpoints without touching your global config. That independence matters more than it sounds, because a card’s “go two-column” threshold has nothing to do with the “switch to tablet layout” threshold, and jamming both onto one scale was always a compromise.

The same card, fixed

Here’s the rewrite. One class on the parent, one prefix swap on the children.

<div class="@container">
  <div class="grid grid-cols-1 @md:grid-cols-2 gap-4">
    <img class="w-full" src="/thumb.jpg" alt="">
    <div>
      <h3 class="text-base @md:text-lg">Product name</h3>
      <p class="text-sm">Short description goes here.</p>
    </div>
  </div>
</div>

Now @md:grid-cols-2 fires when the card’s own container crosses its breakpoint, not when the window does. Drop this into a 300px sidebar and it stays single-column. Drop it into a wide grid cell and it goes two-up. Same markup, no context prop, no duplicate class sets. The first time I saw the sidebar version behave on its own, I actually said “oh” out loud at my desk.

What makes this feel like a real upgrade rather than a party trick is that the card is now honest about its own responsiveness. I can build it once, write a single story for it in the component explorer, and trust it everywhere. When a teammate reuses it in a layout I’ve never seen, I don’t have to answer a Slack message about which prop controls the columns, because there isn’t one. The component measures its own box and reacts. That’s the whole promise of component-driven design, and container queries are the missing piece that finally makes it true for layout, not just for styling.

Named containers, and the trap I fell into

Sometimes a child needs to respond to a specific ancestor, not the nearest one. Tailwind lets you name containers with @container/name and then target them with @lg/name: variants:

<div class="@container/card">
  <div class="@container/media">
    <p class="@lg/card:text-lg">Reads the card, not the media wrapper.</p>
  </div>
</div>

The trap I hit: I forgot that @container sets container-type: inline-size, which establishes a new containment context, and that changes how some absolutely positioned children resolve their sizing. A tooltip I had positioned against the viewport suddenly anchored to the card instead. Ten confused minutes later I moved the @container down a level and it was fine. Worth knowing before it bites you. The official Tailwind responsive design docs spell out the full variant list, including @max-md: for styling below a container width rather than above it.

When I still use media queries

Container queries didn’t kill media queries for me, and I don’t think they should. Page-level layout, the overall column structure of a route, the decision to show or hide a whole navigation rail, all of that is genuinely about the viewport, and a media query says exactly that. I use md: and friends for the shell of a page and @md: for the components that live inside it. That split has been clean in practice. Reusable components get container queries so they stop caring where they’re placed. The page skeleton keeps screen breakpoints. I dug into the broader v4 changes in my notes on the CSS-first config, and container queries being built in is the change I’ve gotten the most mileage out of.

There’s a browser-support footnote too. Container queries are supported across current browsers, but if you still owe support to something ancient, check your matrix before you lean on them for critical layout. For anything modern you’re fine. My rough rule: if the layout would be broken and unusable without the query, confirm support first; if the query is a progressive nicety and the single-column fallback is perfectly readable, ship it and move on. Most component-level queries fall into that second bucket, which is why I stopped worrying about it after the first project.

What to try this week

Take one component you’ve reused in two different widths, the one with the awkward context prop or the duplicated md: and lg: classes. Wrap it in @container, swap the screen prefixes for @ prefixes on its internals, and delete the prop. Resize the parent, not the window, and watch it adapt on its own. If you build a component library, this is the pattern that makes cards, media objects, and stat tiles portable, and it’s the kind of frontend cleanup I end up doing across most of my project work.