Tailwind v4 Container Queries — Component-First Responsive Design Without Plugins

Tailwind v4 container queries ship natively — drop the plugin, use @container plus @sm:/@md: to make components that respond to their slot, not the viewport.

Steven Richardson
Steven Richardson
· 10 min read

You build a card component that looks great in a hero. Drop it into a 320px dashboard sidebar and the avatar collides with the title, the meta row wraps onto three lines, and the action button sits on its own. The component does not know it is in a sidebar. It only knows the viewport, and the viewport says "desktop". That is the viewport breakpoint trap, and the fix is container queries — Tailwind v4 finally ships them natively, no plugin, no config.

This is a concept piece with code you can paste. If you are still on the v3 plugin, drop it. Everything below works on a fresh npm create vite@latest Laravel + Tailwind v4 project.

The viewport breakpoint trap#

The whole responsive-design model in Tailwind v3 hung on viewport breakpoints. md:grid-cols-2 means "when the browser window is at least 768px wide, switch to two columns". That is fine for the page shell — the header collapses, the nav becomes a hamburger, the layout reflows. It falls apart for components that live in more than one slot.

Take a <ProductCard> Livewire component. On the marketing page it sits in a three-column grid at full width. In the dashboard it lives in a 320px sidebar. In the admin index it sits inside a 720px main column. Three different widths, three correct layouts — and zero of them is the viewport. The viewport is whatever the user picked.

You can work around this with prop-driven variants (<ProductCard layout="narrow" />), but you end up branching the Blade template, multiplying class strings, and chasing edge cases when the parent grid rearranges itself. CSS knows the slot size already. Container queries let you ask it.

How @container works in Tailwind v4#

In Tailwind v3 you installed @tailwindcss/container-queries and added @plugin "@tailwindcss/container-queries" to your CSS. In v4 you delete both. Container queries are first-class — @container and the @sm:/@md:/@lg: variants are built in. The same CSS-first philosophy that powers the Tailwind v4 dark mode setup with @custom-variant drives this: less JavaScript config, more CSS primitives.

Mark a parent as a query container with the @container utility. Children then use @sm: through @7xl: variants that fire based on the container's width:

<div class="@container">
    <div class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3">
        @foreach ($products as $product)
            <x-product-card :$product />
        @endforeach
    </div>
</div>

That @container declaration emits container-type: inline-size under the hood. Inside, @sm: fires at 24rem container width, @md: at 28rem, @lg: at 32rem — same numeric scale as the viewport variants, just sized against the parent. You can sanity-check the full default scale in the Tailwind v4 responsive-design docs.

Max-width variants work too. @max-md:hidden hides the element when the container is narrower than md. Combine them: @md:@max-xl:flex reads as "between md and xl container width, show as flex".

Building a card that adapts to its slot#

Here is the real pattern. One Blade component, one set of classes, three layouts — driven entirely by the slot it lands in.

{{-- resources/views/components/product-card.blade.php --}}
<article class="@container rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
    <div class="flex flex-col gap-4 @sm:flex-row @sm:items-center">
        <img
            src="{{ $product->thumbnail }}"
            alt=""
            class="size-16 rounded-xl @sm:size-20 @lg:size-24"
        >

        <div class="min-w-0 flex-1">
            <h3 class="truncate text-base font-semibold @sm:text-lg">
                {{ $product->name }}
            </h3>

            {{-- Meta row collapses to a single line under sm, stacks below --}}
            <dl class="mt-1 flex flex-col gap-1 text-sm text-zinc-500 @sm:flex-row @sm:gap-4">
                <div><dt class="sr-only">Price</dt><dd>£{{ $product->price }}</dd></div>
                <div><dt class="sr-only">SKU</dt><dd>{{ $product->sku }}</dd></div>
            </dl>
        </div>

        <a
            href="{{ route('products.show', $product) }}"
            class="hidden @md:inline-flex items-center rounded-lg bg-zinc-900 px-3 py-2 text-sm font-medium text-white"
        >
            View
        </a>
    </div>
</article>

Drop that into a 280px sidebar: avatar above the title, meta stacked, action button hidden — the card stays readable. Drop the same component into a 720px main column: avatar left, title and meta inline, action button visible on the right. Same Blade, no props, no JavaScript.

The trick is the @container on the <article> itself, not on the parent grid. Each card defines its own query context, so a four-column grid of cards behaves the same as a single card in a sidebar — the layout responds to the card's measured width, not the page.

Naming containers for nested layouts#

Once you nest @container elements you eventually want to query a specific ancestor — usually the layout shell rather than the nearest wrapper. That is what named containers solve. Add a name with the slash syntax (@container/sidebar), then prefix variants with the same name (@md/sidebar:hidden).

{{-- resources/views/components/layouts/app.blade.php --}}
<div class="flex min-h-screen">
    <aside class="@container/sidebar w-64 shrink-0 border-r border-zinc-200 p-4 dark:border-zinc-800">
        <nav class="space-y-1">
            <a href="/dashboard" class="flex items-center gap-3 rounded-lg px-3 py-2">
                <x-heroicon-o-home class="size-5" />
                {{-- Hide the label whenever the sidebar collapses below md --}}
                <span class="@max-md/sidebar:hidden">Dashboard</span>
            </a>
        </nav>
    </aside>

    <main class="@container/content flex-1 p-6">
        {{ $slot }}
    </main>
</div>

The label inside the nav links queries the sidebar's width — @max-md/sidebar:hidden means "hide when the sidebar is narrower than md". A descendant deep inside $slot can independently query @lg/content: to react to the main column. Two named contexts, no class collisions, no useResizeObserver JavaScript.

I reach for named containers when the nearest unnamed @container is not the one I care about. If the relevant container is always the closest ancestor, leave it unnamed.

Mixing container queries with viewport breakpoints#

Container queries do not replace viewport breakpoints — they live alongside them. Use viewport variants for page chrome (nav, sidebar visibility, off-canvas behaviour) and container variants for components that need to portage between slots:

<div class="grid grid-cols-1 gap-6 lg:grid-cols-[16rem_1fr]">
    {{-- Sidebar: viewport decides whether it appears at all --}}
    <aside class="hidden lg:block"></aside>

    {{-- Main column: container decides how its children lay out --}}
    <section class="@container">
        <article class="grid @md:grid-cols-2 @xl:grid-cols-3"></article>
    </section>
</div>

The grid swaps from one column to two at the viewport lg breakpoint — that is a layout decision. The article inside the <section> decides its own column count based on how much room the parent gave it — that is a component decision. Mix freely. The rule of thumb: if the answer to "what should change?" depends on the page, use md:. If it depends on the slot, use @md:.

Length units that go with container queries#

You will eventually want sizing in container units. cqw (1% of container width), cqi (1% of inline size), cqb (1% of block size) — all four CSS units work as arbitrary values:

<div class="@container">
    <h2 class="text-[clamp(1.25rem,4cqi,2.5rem)] leading-tight">
        Headline that scales with the slot
    </h2>
</div>

That heading grows fluidly between 1.25rem and 2.5rem based on the container's inline size, not the viewport's. The same component drops into a sidebar and a hero and renders correctly at both ends.

Migrating from the v3 plugin#

If you are coming from Tailwind v3 with @tailwindcss/container-queries, the migration is two deletes and zero rewrites — the class syntax is identical.

npm uninstall @tailwindcss/container-queries

Then open resources/css/app.css and remove the plugin import:

  @import "tailwindcss";

- @plugin "@tailwindcss/container-queries";

  @custom-variant dark (&:where(.dark, .dark *));

If you are doing a wider Tailwind v3 → v4 migration first, the Laravel 12 to 13 upgrade guide walks through the broader frontend rebuild — Vite plugin swap, @theme blocks, the tailwind.config.js removal. The container-queries piece is the small win on top of that.

Every existing @sm:flex-row or @max-md:hidden class keeps working. The plugin only existed to register those variants; v4 registers them at the framework level.

Gotchas and Edge Cases#

A few sharp edges that bit me in production.

Containers cannot query themselves. @container on a div, then @md:p-6 on the same div — that does nothing. The query variant only sees containers higher in the tree. Move the variant to a child, or wrap the element in a dedicated @container parent.

Display none breaks contained sizing. A @container with display: none reports a size of 0. Any descendant @md: rule will think the container is narrower than its smallest breakpoint. This bites Alpine x-show and Livewire conditional renders — once the element is removed from the layout tree, querying it returns nothing. Use hidden on a wrapper outside the @container, not inside.

Livewire and Alpine dynamic content recalculates correctly. This trips people up. When Livewire morphs a chunk of DOM, the browser re-resolves container sizes — the @md: rules fire on the new contents without any JavaScript on your part. The same goes for Livewire 4 islands that lazy-load their HTML: once the island swaps in, the surrounding @container measurements drive the new markup. No Livewire.hook('morph.updated', …) required.

Browser support is fine. Stop checking. Chrome 105 (Aug 2022), Firefox 110 (Feb 2023), Safari 16 (Sep 2022). Baseline Widely Available as of October 2023. The only browsers without support are ones that already cannot parse modern CSS — they are not your audience.

The @container utility is not the same as the container utility. Tailwind still ships container (mx-auto plus max-width by breakpoint). @container (with the @ prefix) marks a query container. They share four letters and do completely different things.

Wrapping Up#

The viewport is not the slot. Component portability stops being a fight the moment you accept that and reach for @container and @sm:/@md: instead of md: for component-level layout. Start with one card or sidebar widget today — convert the variants in place, see whether it renders correctly across every slot it already lives in, and migrate outward from there.

Once the cards behave, the next thing that benefits is anything Livewire-driven. If you are still on Livewire 3, the Livewire 3 to 4 migration guide covers the upgrade you will want before pairing container queries with the new island primitives. For admin panels, the Filament v4 custom form field component walkthrough shows how the same @container pattern lets one field render correctly in a wizard step, a section, and a relation manager modal.

FAQ#

What are Tailwind CSS container queries and how are they different from media queries?

Container queries let a component style itself based on the size of its parent container instead of the browser viewport. Media queries answer "how wide is the page?" — container queries answer "how wide is the slot I was rendered into?". In Tailwind v4 you mark a parent with the @container utility and use @sm:/@md:/@lg: variants on its descendants. Those variants fire when the container hits the matching size, regardless of the viewport.

Do I need a plugin to use container queries in Tailwind v4?

No. The @tailwindcss/container-queries plugin existed for v3.2+; in v4 the same variants are built into core. If you are upgrading from v3, run npm uninstall @tailwindcss/container-queries and remove the @plugin "@tailwindcss/container-queries"; line from your CSS. Every existing @sm:/@md: class keeps working unchanged because the variant names are identical.

How do I name a container in Tailwind v4?

Add the name after a slash on the @container utility — @container/sidebar declares a container called sidebar. From any descendant, target it with the same slashed syntax on the variant — @md/sidebar:hidden, @lg/sidebar:grid-cols-2. Names are useful when you have nested @container elements and the closest ancestor is not the one you want to query. Leave containers unnamed when the nearest ancestor is always the right answer.

What is the browser support for CSS container queries in 2026?

Container queries are Baseline Widely Available — supported in Chrome 105+, Firefox 110+, Safari 16+, and Edge 105+. Those minimums shipped in late 2022 and early 2023, so by mid-2026 essentially every browser in your analytics will support them. The only environments that do not are legacy enterprise IE/Edge installs that already cannot parse most modern CSS, and you have bigger problems if those are your target.

Can I mix container queries and viewport media queries in the same component?

Yes, and you should. Viewport variants (md:, lg:) belong on page-level decisions — when the sidebar appears, when the nav collapses, when off-canvas activates. Container variants (@md:, @lg:) belong on component-internal decisions — when a card switches from stacked to inline, when a form field reveals a help column. Combining them on the same element is fine: md:hidden @lg:flex means "hidden on small viewports, but show as flex when the local container is at least lg".

How do container queries work with Livewire and dynamic content?

They work transparently. Container sizing is computed by the browser's layout engine, so when Livewire morphs a fragment of DOM into place, the browser re-resolves the surrounding @container and the child variants fire against the new size. You do not need Livewire lifecycle hooks or Alpine watchers. The one trap is dynamically toggling a container's display: none — a hidden container reports zero width, so all its descendant @md: rules read as false. If you need to hide and show the slot, place hidden on a wrapper outside the @container parent, not on the container itself.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.