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.
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:
| Viewport | Columns | Card width | Inner padding (p-4 md:p-6) |
|---|---|---|---|
| 767px | 2 | ~353px | 16px |
| 768px | 2 | ~353px | 24px |
| 1023px | 3 → still 2 | ~481px | 24px |
| 1024px | 3 | ~311px | 24px |
| 1279px | 3 | ~396px | 24px |
| 1280px | 3 | ~380px | 24px |
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:) | |
|---|---|---|
| Measures | the viewport | the nearest ancestor with container-type |
| Correct for | page layout, global chrome, column counts | components that appear at several widths |
| Compiles to | @media (width >= 48rem) | @container (width >= 28rem) |
| Default scale | sm 640px → 2xl 1536px | @3xs 256px → @xl 576px and up |
| Breaks when | a component's width is not a function of the viewport | you forget @container on the parent |
| Plugin needed in v4 | no | no — 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
| Mistake | How it shows up | Fix |
|---|---|---|
@container with no @-variants querying it | A container-type declaration doing nothing; the exact bug audited above | Query it, or remove the class |
Assuming @md ≈ md | The rule never fires — 448px is wide for a card in a grid | Check the real element width; reach for @min-[340px]: when no named size fits |
| Putting the variant on the container itself | Nothing happens; an element cannot query its own container | Move the utility to a descendant, or add a wrapper |
| Using container queries for page layout | Extra containment contexts for no benefit | Page-level structure is what media queries are for |
| Forgetting containment has side effects | container-type: inline-size also applies layout and style containment | Only declare it where you intend to query it |
| Naming nothing when contexts nest | An 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.
