December 9, 20255 min read

Container queries and :has() in Tailwind 4

A few months ago I wrote about why you should build mobile first. I still think the same: designing for the small screen first forces you to decide what matters.

What has changed is the tooling. That article assumed that adapting a layout was a job for @media, and since then browsers let you ask a much more useful question.

The problem: the screen isn’t the container

A media query asks “how wide is the window?”. But your article card doesn’t live in the window: it lives inside a column, which is sometimes the main content and sometimes a 300px sidebar.

With @media, the card has no way of knowing. On a desktop screen the media query says “large”, so the card goes horizontal, including inside the narrow sidebar, where it looks terrible.

The classic answer was to pass a prop: <Card variant="compact" />. It works, but it means every place that uses the component has to remember to tell it how to look. And the more variants there are, the easier it is for them to drift out of sync.

A container query changes the question: “how wide is the space I’ve been given?”. The component adapts on its own, wherever you put it.

The minimum you need to know

There are two pieces. First, mark an element as a queryable container:

.column {
  container-type: inline-size;
}

Then ask about its width:

@container (min-width: 30rem) {
  .card { flex-direction: row; }
}

inline-size means “query the width only”. It’s what you’ll want almost every time; further down I explain why the other value can break your layout.

In Tailwind 4 this comes built in, with no plugin. The @container class marks the container and the @sm:, @md:, @lg: variants query its width:

<div class="@container">
  <article class="flex flex-col @md:flex-row @md:items-center gap-4">
    <img class="w-full @md:w-2/6 rounded-2xl" src="/cover.webp" alt="" />
    <h2 class="text-xl @md:text-2xl">A title that adapts to its column</h2>
  </article>
</div>

That component no longer needs to know where it is. With the same markup, it goes vertical in the sidebar and horizontal in the main content.

Careful: @md isn’t md

It’s the easiest mistake to make. Container breakpoints and screen breakpoints are different scales, even though they share names. They come from two separate groups of variables in Tailwind:

Variant Queries Value
sm: viewport 40rem
md: viewport 48rem
lg: viewport 64rem
@sm: container 24rem
@md: container 28rem
@lg: container 32rem

In other words, @md: kicks in at 28rem, not 48rem. If you translate your breakpoints one to one, everything will change much sooner than you expect and you’ll think container queries don’t work.

And it makes sense: a container is almost never as wide as the screen, so its useful scale is smaller. When you need a specific value, the arbitrary syntax is clearer:

<div class="@min-[30rem]:grid-cols-2"></div>

Named containers

As soon as you nest containers you get an ambiguity: a container query checks the nearest container, and sometimes that isn’t the one you want. That’s what names are for:

<aside class="@container/panel">
  <div class="@container/card">
    <!-- queries the panel, skipping the card -->
    <p class="@lg/panel:text-lg">…</p>
  </div>
</aside>

Tailwind compiles that to container: panel/inline-size and @container panel (min-width: 32rem). My advice is to name them as soon as there are two nested containers. Debugging a query that checks the wrong container is quite annoying, because everything looks correct.

What container-type implies

This explains a lot of “my layout broke and I don’t know why” cases.

Declaring container-type: inline-size applies containment: you’re telling the browser that the element’s width can be worked out without looking at what’s inside it. That’s what makes the query possible, but it also takes away something you may have been relying on: the element can no longer shrink to fit its content.

In practice, a div with container-type: inline-size and width: fit-content doesn’t behave the way you’d expect. So:

  • Put the container on the layout wrapper (the column, the grid cell), not on the component you want to adapt.
  • Never use container-type: size unless you know exactly what you’re doing: it also queries the height and requires vertical containment, which collapses elements that don’t have an explicit height.

In short: the parent is the container and the child is the one that adapts.

:has(), the parent selector

:has() selects an element based on what it contains. People asked for a selector like this for years, and it solves things that used to need JavaScript.

/* a card with an image is laid out differently from one without */
.card:has(img) { padding-top: 0; }

In Tailwind it’s the has-* variant:

<article class="p-6 has-[img]:p-0 has-[img]:overflow-hidden">
  <img src="/cover.webp" alt="" />
</article>

Where it shows most is in forms, because it lets you react to state without JavaScript:

<label class="flex gap-3 rounded-xl border p-4 has-[:checked]:border-mint-500 has-[:checked]:bg-mint-50 has-[:focus-visible]:ring-2">
  <input type="radio" name="plan" />
  <span>Monthly plan</span>
</label>

Look at has-[:focus-visible]:ring-2: the focus ring shows up on the whole card when the radio inside it gets keyboard focus. That used to need a JavaScript listener; now it’s a class, and it improves accessibility too.

There are also combinations with group and peer:

  • group-has-[img]:: the child changes because the group contains an image.
  • peer-has-checked:: an element changes because its sibling contains something checked.

Both together

The two ideas complement each other well. The container query answers how much space do I have; :has() answers what do I contain:

<div class="@container">
  <article
    class="flex flex-col gap-4 rounded-2xl p-6
           @md:flex-row @md:items-center
           has-[img]:p-0 has-[img]:@md:pr-6"
  >
    <img class="w-full rounded-2xl @md:w-2/6" src="/cover.webp" alt="" />
    <h2 class="text-xl @md:text-2xl">It adapts to its column and to its content</h2>
  </article>
</div>

That component receives no presentation props and works the same on the front page, in the sidebar and on a tag page. That used to take three variants.

So are media queries obsolete?

No. The work gets split like this:

  • Media queries for what depends on the device: the number of columns on the page, whether the navigation is a bar or a hamburger menu, and everything that isn’t size, like prefers-reduced-motion, prefers-color-scheme or print.
  • Container queries for reusable components, which is usually where most of a site’s responsive CSS lives and where it simplifies the most.

The mobile first approach stays the same. What changes is who decides: before, the page told each component how to look, and now each component decides based on the space it has.

Share