Skip to main content
ASoc
Tutorial

Tailwind Container Queries: One @container, Zero Queries

Our own home page had the container context and none of the queries — so a card loses a third of its width at lg while its padding never moves. The arithmetic and the fix.

The ASoc Team9 min read

Tailwind container queries style an element by the width of its nearest container instead of the viewport: add @container to a parent, then use @sm:, @md: or @min-[340px]: on its descendants. They are built into Tailwind v4 with no plugin. Auditing this storefront turned up one @container and zero queries against it.

The audit result, first

Grepping src/ on 2026-08-26 across 91 components: one @container class, on TechStackCard's outer element, and zero container-query variants anywhere in the codebase. No @sm:, no @md:, no arbitrary @min-[…]:.

// src/components/molecules/TechStackCard.tsx
<div className="group @container rounded-3xl border border-stroke-secondary
     bg-gray-50 p-1 duration-200 hover:border-primary-200 hover:bg-primary-25 md:p-2">
  <div className="relative h-full rounded-2xl border border-[#F2F4F7] bg-white p-4 md:p-6">

@container compiles to exactly one declaration — container-type: inline-size — and that declaration only does something if a descendant queries it. Nothing does. Somebody reached for the right tool, established the containment context, and then wrote the padding against the viewport anyway: md:p-2 on the container, md:p-6 on the child.

That is worth writing up rather than quietly deleting, because the padding it left behind is wrong in a way that is genuinely hard to see, and the arithmetic shows exactly what container queries are for.

Why the viewport is the wrong ruler here

These cards live in a grid that changes column count, and — this is the part that matters — the grid is not inside the page container. It is full-bleed with its own horizontal padding:

// src/components/organisms/TechStack.tsx
<div className="px-4 min-[1800px]:px-[115px] xl:px-10 2xl:px-16">
  <div className="grid gap-7.5 sm:grid-cols-2 lg:grid-cols-3">
    {techStackCards.map((c) => <TechStackCard key={c.title} {...c} />)}
  </div>
</div>

So a card's width is (viewport − 2 × padding − gaps) ÷ columns, with gap-7.5 = 30px. Work that out at the four viewport widths where something changes:

ViewportColumnsCard widthInner padding (p-4 md:p-6)
767px2~353px16px
768px2~353px24px
1023px3 → still 2~481px24px
1024px3~311px24px
1279px3~396px24px
1280px3~380px24px

Read the two bold rows together and the defect states itself:

  • At 768px the padding grows by 50% while the card's width does not change at all. The md: breakpoint fires on a viewport event the card cannot perceive.
  • At 1024px the card loses 170px — a third of its width — and the padding does not move. The one moment the card genuinely needs less padding is the one moment md: has nothing to say, because it fired 256px earlier and min-width variants never fire again.

The card is also narrower on a 1024px laptop than on a 900px tablet, which is the general case that breaks viewport-keyed component styling: in a responsive grid, "bigger screen" and "bigger card" are not the same event, and sometimes they are opposite events.

Media query versus container query

Media query (md:)Container query (@md:)
Measuresthe viewportthe nearest ancestor with container-type
Correct forpage layout, global chrome, column countscomponents that appear at several widths
Compiles to@media (width >= 48rem)@container (width >= 28rem)
Default scalesm 640px → 2xl 1536px@3xs 256px → @xl 576px and up
Breaks whena component's width is not a function of the viewportyou forget @container on the parent
Plugin needed in v4nono — built in since v4

The size scales are deliberately different, and mixing them up is the first thing to get wrong. @md is 28rem (448px) — a container size, not a viewport size. Writing @md:p-6 expecting it to behave like md:p-6 gives you a rule that never fires, because a card in a three-column grid is rarely 448px wide.

The fix, in one utility

The container context already exists. The change is to key the child's padding to it:

  <div className="group @container rounded-3xl border border-stroke-secondary
       bg-gray-50 p-1 duration-200 hover:border-primary-200 hover:bg-primary-25 md:p-2">
-   <div className="relative h-full rounded-2xl border border-[#F2F4F7] bg-white p-4 md:p-6">
+   <div className="relative h-full rounded-2xl border border-[#F2F4F7] bg-white p-4 @xs:p-6">

@xs is 20rem (320px), which lands between the narrow states (~289px at sm, ~311px at lg) and the wide ones (~353px at md, ~396px at xl). Padding now tracks the card's actual width: compact when it is one of three columns, generous when it is one of two. Compiled, it is @container (width >= 20rem).

One gotcha the diff hides: an element cannot query its own container. The outer div carries @container, so its own md:p-2 cannot become @xs:p-2 — the containment context it establishes is queryable only by its descendants. Fixing the outer padding too would need a wrapper, which for 4px of padding is not worth an element.

This is deliberately not applied in this release. The section is a pixel-faithful port and the fix changes rendered padding on the home page between 1024px and 1280px; changing that inside a post about it is the wrong order, and it is the same convention this codebase used for the dynamicParams asymmetry on /templates/[slug].

Where else this pattern is load-bearing

Container queries earn their keep exactly where a component is reused at several widths, and this storefront has three such places beyond the tech-stack row. Product cards render in the templates explorer grid, in the six-wide related-products rail at the bottom of every product page, and on the framework landing hubs — three different column counts for the same component. The dashboard's owned-product cards render in a narrower column than the storefront grid does. And the preview modal lays its iframe out at a chosen device width and CSS-scales it to fit, which is a manual version of the same idea.

None of those are broken today, because their internals are largely width-agnostic. The rule of thumb worth carrying: the moment a component's padding, font size or internal layout differs between two of its placements, that is a container query, not a media query. If it only ever appears in one place at one width, md: is simpler and correct.

Mistakes and how they show up

MistakeHow it shows upFix
@container with no @-variants querying itA container-type declaration doing nothing; the exact bug audited aboveQuery it, or remove the class
Assuming @mdmdThe rule never fires — 448px is wide for a card in a gridCheck the real element width; reach for @min-[340px]: when no named size fits
Putting the variant on the container itselfNothing happens; an element cannot query its own containerMove the utility to a descendant, or add a wrapper
Using container queries for page layoutExtra containment contexts for no benefitPage-level structure is what media queries are for
Forgetting containment has side effectscontainer-type: inline-size also applies layout and style containmentOnly declare it where you intend to query it
Naming nothing when contexts nestAn inner container swallows the query you meant for the outer one@container/card and @lg/card: to target by name

Frequently asked questions

Do I need a plugin for container queries in Tailwind? Not in v4. @container and the @sm:/@md: variants are part of core, and this project — which installs no Tailwind plugins at all beyond @tailwindcss/postcss — compiles them out of the box. The @tailwindcss/container-queries plugin was the v3 answer; if you are on v4 and still installing it, you can drop it.

Why is @md 448px when md is 768px? They are different scales for different jobs. Viewport breakpoints are sized for screens; container sizes are sized for components, running from @3xs at 256px up. A component that fills a 768px viewport is never 768px wide itself once you subtract page padding and grid gaps, so a shared scale would be wrong for both.

Does @container cost anything if nothing queries it? Yes, though not much: container-type: inline-size applies layout, style and inline-size containment to the element, which makes it a containing block and stops its inline size responding to its contents. In a grid where the track already fixes the width, that is close to a no-op — but it is a real declaration with real semantics, shipped to every visitor, achieving nothing. Delete it or use it.

What is browser support like? Container queries are supported across current Chrome, Edge, Safari and Firefox, and have been since early 2023. For a marketplace whose traffic is developers on evergreen browsers this is a non-issue; for a product with a long-tail browser matrix, the graceful failure is that the query never matches, so write the base state as the acceptable one — p-4 here — and treat the query as the enhancement.

When should I still use md: instead? Whenever the thing you are sizing genuinely is a function of the window: page grids, the global navigation seam, whether a modal goes full-screen. How this codebase's 325 viewport variants are distributed across three different seams covers that side, including the two breakpoints declared in @theme that nothing uses.

Templates where component-relative layout matters

ASoc Apex spans 115+ pages with a deep component showcase — cards, chart galleries, datatables — which is exactly the surface area where the same widget lands in several column widths. ASoc Clover pairs Default, Sales and Finance dashboards with a UI kit of charts, components and tables. ASoc Pulse runs five dashboards plus a full store back office, dark mode on every screen.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the token layer these variants are generated from, see the Tailwind v4 migration guide.

Keep reading

Tutorial11 min read

Tailwind v4 Dark Mode: We Wrote 480 Variants and Shipped No Toggle

One @custom-variant line replaces darkMode: 'class'. Then you choose: dark: at every call site, or tokens under .dark. We picked the first 480 times — and found nothing can turn it on.

Read more
Tutorial9 min read

A Tailwind Data Table Audit: One Real Table, Two Real Defects

The only <table> in this codebase, read cell by cell: what it gets right, the missing caption and text-free checkmarks it shipped with, and the fix.

Read more
Tutorial9 min read

Tailwind Design Tokens: 41 Declared, 22 Values That Went Around Them

41 tokens across three of Tailwind v4's 19 namespaces, and 161 utilities that spell a value literally instead. 22 of those literals were already a token in the same stylesheet.

Read more