CSS Container Queries — Component-Local Responsive Design Beyond Viewport Media Queries
Deep dive into @container, container-type, cqi units, style queries, containment costs, and the gotchas senior engineers hit when replacing media queries.
Media queries answer one question: how wide is the viewport? {Media query trả lời một câu: viewport rộng bao nhiêu?}
Container queries answer a different one: how wide is the slot this component actually occupies? {Container query trả lời câu khác: component thực sự chiếm slot rộng bao nhiêu?}
That shift — from page-level breakpoints to component-level breakpoints — is the biggest change in responsive CSS since flexbox went mainstream {Thay đổi đó — từ breakpoint cấp trang sang breakpoint cấp component — là thay đổi lớn nhất trong responsive CSS kể từ khi flexbox phổ biến}.
This post is a senior-engineer map: when to use @container, how container-type and container-name work, container query units, style queries in 2025–2026, and the containment gotchas that bite in production {Bài viết này là bản đồ cho senior engineer: khi nào dùng @container, container-type và container-name hoạt động thế nào, container query units, style queries 2025–2026, và các gotcha containment cắn trong production}.
Table of contents
- Why viewport media queries fail components
- The container query model
container-type: inline-size, size, normalcontainer-nameand named@containerrules- Container query units (
cqw,cqi,cqh, …) - Style queries —
@container style(...)in 2025–2026 - Gotchas, containment side effects, and debugging
- Real component: product card in a dashboard grid
- Live demo
- Decision checklist
1. Why viewport media queries fail components
Consider a reusable <ProductCard> dropped into three places on the same page {Hãy xem <ProductCard> tái sử dụng được đặt ở ba chỗ trên cùng một trang}:
- a 240px sidebar {sidebar 240px}
- a 360px “related items” rail {rail “related items” 360px}
- a fluid main grid column that might be 600px or 900px depending on layout {cột grid main fluid có thể 600px hoặc 900px tùy layout}
With @media (min-width: 480px) you get one answer for all three instances {Với @media (min-width: 480px) bạn nhận một câu trả lời cho cả ba instance} — because they share the same viewport {vì chúng chia sẻ cùng viewport}.
The sidebar card gets a horizontal layout it cannot fit; the main column card stays stacked when it has room to expand {Card sidebar nhận layout ngang không vừa; card main vẫn xếp dọc dù còn chỗ mở rộng}.
Before container queries, teams patched this with:
| Approach | Problem {Vấn đề} |
|---|---|
Duplicate components (ProductCardCompact, ProductCardWide) | Prop explosion, drift between variants {Bùng nổ prop, lệch giữa variant} |
Parent passes a layout="compact" prop | Every parent must know breakpoints; breaks encapsulation {Mọi parent phải biết breakpoint; phá encapsulation} |
ResizeObserver + class toggling in JS | Layout flash, hydration mismatch in SSR, test burden {Flash layout, hydration mismatch SSR, gánh nặng test} |
@media tuned to “most common” viewport | Wrong in sidebars, modals, split panes, zoomed UI {Sai trong sidebar, modal, split pane, UI zoom} |
Container queries move the breakpoint into the component’s CSS where the layout knowledge belongs {Container query đưa breakpoint vào CSS của component — nơi kiến thức layout thuộc về}. The card asks its ancestor container “how much inline space do I have?” and adapts {Card hỏi ancestor container “tôi có bao nhiêu không gian inline?” rồi thích ứng}.
Principal insight: Responsive design is not “mobile vs desktop.” It is contextual density — how much information fits in the box the component is given. Container queries encode that directly. {Insight principal: Responsive design không phải “mobile vs desktop.” Mà là mật độ theo ngữ cảnh — bao nhiêu thông tin vừa hộp component được cấp. Container query mã hóa điều đó trực tiếp.}
2. The container query model
A query container is an ancestor element you mark with container-type (and optionally container-name) {Query container là element ancestor bạn đánh dấu bằng container-type (và tuỳ chọn container-name)}.
Descendants inside that subtree can use @container rules that match against that element’s size (or custom properties, for style queries) {Descendant trong subtree đó dùng rule @container khớp kích thước element đó (hoặc custom property, với style query)}.
┌─────────────────────────────────────────────┐
.card-slot ← query container
│ container-type: inline-size
│ container-name: card
│
│ ┌─────────────────────────────────────┐
│ │ .product-card ← styled by @container │
│ │ (cannot be the container itself) │
│ └─────────────────────────────────────┘
└─────────────────────────────────────────────┘
Viewport width: 1280px ← ignored by @container
Container width: 320px ← what @container (min-width: 280px) evaluates
The evaluation flow at style time {Luồng đánh giá lúc style}:
- Browser finds nearest qualifying ancestor container for each
@containerrule {Browser tìm ancestor container đủ điều kiện gần nhất cho mỗi rule@container}. - If
container-nameis specified in the rule, it must match a named ancestor {Nếu rule chỉ địnhcontainer-name, phải khớp ancestor có tên}. - Size conditions (
min-width,max-width,min-height, …) compare against the container’s query size — typically inline size when usinginline-sizecontainment {Điều kiện kích thước so với query size của container — thường là inline size khi dùng containmentinline-size}. - Matching rules cascade like ordinary CSS; later rules override earlier ones at the same specificity {Rule khớp cascade như CSS thường; rule sau override rule trước cùng specificity}.
3. container-type: inline-size, size, normal
container-type declares what dimensions descendants can query {container-type khai báo descendant có thể query kích thước nào}.
| Value | Queries available | Containment applied | Typical use |
|---|---|---|---|
normal | None (default) | None | Non-container elements |
inline-size | Width / inline axis | Size containment on inline axis | Most UI components — cards, alerts, nav items |
size | Width and height | Full size containment | Charts, maps, fixed-aspect panels where height queries matter |
inline-size size (shorthand) | Both axes explicitly | Combined | Rare; prefer size when you need both |
.card-slot {
container-type: inline-size;
/* shorthand equivalent: */
/* container: card / inline-size; */
}
The layout-containment cost
Setting container-type: inline-size or size applies layout containment on the corresponding axes {Đặt container-type: inline-size hoặc size áp dụng layout containment trên trục tương ứng}.
That is not free semantics — it changes layout behavior {Đó không phải semantics miễn phí — nó đổi hành vi layout}:
- The container becomes a containing block for absolutely positioned descendants in more cases {Container trở thành containing block cho descendant absolute trong nhiều trường hợp hơn}.
- Percentage heights on children may resolve differently because the container’s block size is no longer tied to content in the same way under
sizecontainment {Chiều cao phần trăm trên child có thể resolve khác vì block size container không còn gắn content cùng cách dướisizecontainment}. - Floats and margin-collapsing behavior can change at the containment boundary {Float và margin-collapsing có thể đổi tại ranh giới containment}.
Rule of thumb: Start with
inline-sizeonly. Reach forsizewhen you genuinely need@container (min-height: …)— e.g. a widget that switches layout when its panel grows vertically, not just wider. {Quy tắc ngón tay cái: Bắt đầu chỉinline-size. Dùngsizekhi thực sự cần@container (min-height: …)— vd widget đổi layout khi panel cao thêm, không chỉ rộng hơn.}
For a card list, inline-size is almost always the right trade-off: you query width, you avoid the heavier block-axis containment unless needed {Với danh sách card, inline-size hầu như luôn đúng: query width, tránh block-axis containment nặng hơn trừ khi cần}.
4. container-name and named @container rules
Anonymous containers work when each component has one obvious wrapper {Container ẩn danh ổn khi mỗi component có một wrapper rõ ràng}. Named containers matter when you have nested query contexts or multiple containers in one subtree {Container có tên quan trọng khi có lồng query context hoặc nhiều container trong một subtree}.
.dashboard-panel {
container-type: inline-size;
container-name: panel;
}
.product-card-wrapper {
container-type: inline-size;
container-name: card;
}
/* Respond to the CARD slot, not the outer panel */
@container card (min-width: 400px) {
.product-card {
grid-template-columns: 140px 1fr auto;
}
}
/* Respond to the PANEL — e.g. show a toolbar when the whole panel is wide */
@container panel (min-width: 720px) {
.panel-toolbar {
display: flex;
}
}
Shorthand:
.product-card-wrapper {
container: card / inline-size;
/* name: card, type: inline-size */
}
| Syntax | Meaning |
|---|---|
@container (min-width: 400px) | Nearest ancestor with any non-normal container-type |
@container card (min-width: 400px) | Nearest ancestor named card |
@container panel (400px <= inline-size <= 800px) | Range syntax (modern browsers) |
If a named container is not found, the @container block is ignored (not a parse error) — a common source of “my query never fires” bugs {Nếu không tìm thấy container có tên, block @container bị bỏ qua — nguồn bug “query không bao giờ chạy” phổ biến}.
5. Container query units (cqw, cqi, cqh, …)
Container query units are to @container what vw/vh are to the viewport — but relative to the query container {Container query units tương tự vw/vh với viewport — nhưng tương đối query container}.
| Unit | Definition |
|---|---|
cqw | 1% of query container width |
cqh | 1% of query container height |
cqi | 1% of query container inline size |
cqb | 1% of query container block size |
cqmin | min(cqi, cqb) |
cqmax | max(cqi, cqb) |
.product-title {
/* Scale with the card slot, clamped for accessibility */
font-size: clamp(0.875rem, 4.5cqi, 1.25rem);
}
.product-badge {
width: 12cqw;
max-width: 3rem;
}
Use cases senior teams actually ship {Use case team senior thực sự ship}:
- Fluid typography inside a component without
@mediastair-steps {Typography fluid trong component không cần bậc@media} - Icon or avatar sizing proportional to a widget frame {Icon/avatar tỉ lệ khung widget}
- Internal padding that grows with container (
padding: 2cqi) for dense vs spacious modes {Padding nội bộ tăng theo container cho mode dày vs thoáng}
Gotcha: Query units resolve against the selected query container. If you forget
container-typeon the wrapper,cqifalls back to initial containing block behavior (effectively viewport-ish), which looks “almost right” in dev and wrong in a sidebar. {Gotcha: Query units resolve theo query container đã chọn. Quêncontainer-typetrên wrapper thìcqifallback hành vi initial containing block (gần viewport), trông “gần đúng” khi dev và sai trong sidebar.}
6. Style queries — @container style(...) in 2025–2026
Size queries answer “how big?” Style queries answer “what theme or variant is the container in?” {Size query trả lời “lớn bao nhiêu?” Style query trả lời “container đang theme/variant gì?”}.
.card-slot {
container-type: inline-size;
--variant: default;
}
.card-slot[data-variant="featured"] {
--variant: featured;
}
@container style(--variant: featured) {
.product-card {
border-color: var(--color-accent);
}
.product-title {
font-weight: 700;
}
}
This decouples visual variant from size — a featured card can get accent styling even in a narrow sidebar, as long as the container exposes --variant {Tách visual variant khỏi size — card featured có accent dù sidebar hẹp, miễn container expose --variant}.
Browser support snapshot (2025–2026)
| Feature | Chromium | Firefox | Safari |
|---|---|---|---|
@container size queries | ✅ Stable | ✅ Stable | ✅ Stable (16+) |
| Container query units | ✅ Stable | ✅ Stable | ✅ Stable |
@container style(...) custom properties | ✅ Stable | ✅ Stable (128+) | ✅ Stable (18+) |
@container style(...) non-custom properties (e.g. display) | ⚠️ Limited / evolving | ⚠️ Limited | ⚠️ Limited |
For production, treat custom-property style queries as the supported subset {Production nên coi style query custom property là tập được hỗ trợ}.
Querying arbitrary computed properties (style(display: grid)) is still inconsistent — prefer exposing intent via --layout-mode: grid on the container {Query computed property tùy ý vẫn không nhất quán — nên expose ý định qua --layout-mode: grid trên container}.
Combine size + style when both matter:
@container card (min-width: 480px) {
.product-card { flex-direction: row; }
}
@container style(--variant: featured) {
.product-card { outline: 2px solid var(--color-accent); }
}
7. Gotchas, containment side effects, and debugging
You cannot query the element itself
The element with container-type is not styled by @container rules targeting itself — only descendants are {Element có container-type không được style bởi rule @container nhắm chính nó — chỉ descendant}.
<!-- ❌ @container rules on .card won't apply TO .card -->
<article class="card" style="container-type: inline-size">…</article>
<!-- ✅ wrapper is the container; card is the query subject -->
<div class="card-slot">
<article class="card">…</article>
</div>
If your design has no extra wrapper, use a subgrid-less inner wrapper or accept one DOM node as the query boundary — the cost is one div, the benefit is correct encapsulation {Nếu design không có wrapper thừa, dùng inner wrapper hoặc chấp nhận một node DOM làm ranh giới query — chi phí một div, lợi ích encapsulation đúng}.
Ancestor must establish a size
A percentage-width child inside an indefinite-size flex/grid track may leave the container at 0px or min-content width — and every @container (min-width: …) rule fails silently {Child width phần trăm trong flex/grid track không xác định có thể để container 0px hoặc min-content — mọi rule @container (min-width: …) fail im lặng}.
Fix the parent layout first: give the slot min-width: 0, explicit flex: 1, or a defined grid track {Sửa layout parent trước: cho slot min-width: 0, flex: 1 rõ, hoặc grid track xác định}.
@container does not replace all @media
Use media queries for global concerns: page gutters, navigation mode, print styles, prefers-reduced-motion, prefers-color-scheme {Dùng media query cho concern toàn cục: gutter trang, mode navigation, print, prefers-reduced-motion, prefers-color-scheme}.
Use container queries for component density inside variable slots {Dùng container query cho mật độ component trong slot biến đổi}.
| Concern | Tool |
|---|---|
| Sidebar vs top nav at 768px viewport | @media |
| Card horizontal vs stacked in 280px vs 480px slot | @container |
| Dark mode | @media (prefers-color-scheme) or :root tokens |
| Featured variant styling | @container style(--variant: featured) |
Debugging in DevTools
Chrome and Firefox DevTools (2025+) expose container overlays and list which @container rules matched for a selected element {Chrome và Firefox DevTools (2025+) có container overlay và liệt rule @container khớp cho element chọn}.
If rules show “not matched,” walk up the tree: is container-type present? Is the name correct? Is the container’s computed inline size what you expect? {Nếu rule “not matched,” đi lên cây: có container-type? Tên đúng? Inline size computed của container đúng kỳ vọng?}
Containment side effect:
container-type: sizeprevents the container’s block size from depending on descendant content height in the standard way. A container withsizeand no explicit height can collapse or clip unexpectedly. Preferinline-sizeuntil you have a measured height strategy. {Tác dụng phụ containment:container-type: sizengăn block size container phụ thuộc chiều cao descendant theo cách thường. Containersizekhông height explicit có thể sụp hoặc clip bất ngờ. Ưu tiêninline-sizeđến khi có chiến lược height đo được.}
8. Real component: product card in a dashboard grid
Below is a pattern you can drop into a design system: one ProductCard component, three @container tiers, no JS resize listeners {Dưới đây là pattern có thể đưa vào design system: một ProductCard, ba tầng @container, không listener resize JS}.
<div class="product-slot">
<article class="product-card">
<img class="product-thumb" src="/img/kb-pro.webp" alt="" />
<div class="product-body">
<h3 class="product-title">Mechanical Keyboard — Tactile Pro</h3>
<p class="product-sku">SKU · KB-7742</p>
<p class="product-desc">Hot-swappable switches, gasket mount.</p>
<footer class="product-actions">
<span class="product-price">$189</span>
<button type="button">Add to cart</button>
</footer>
</div>
</article>
</div>
.product-slot {
container: product / inline-size;
min-width: 0; /* critical in flex/grid parents */
}
.product-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.product-thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.product-desc,
.product-actions {
display: none;
}
@container product (min-width: 280px) {
.product-card {
flex-direction: row;
align-items: flex-start;
}
.product-thumb {
width: 5.5rem;
aspect-ratio: 1;
flex-shrink: 0;
}
}
@container product (min-width: 480px) {
.product-desc,
.product-actions {
display: flex;
}
.product-title {
font-size: clamp(0.875rem, 4cqi, 1.125rem);
}
}
The same markup renders compact in a kanban column, horizontal in a catalog rail, and expanded in the main grid — without the parent passing variant="wide" {Cùng markup render compact trong cột kanban, ngang trong rail catalog, mở rộng trong grid main — không cần parent truyền variant="wide"}.
Migration from ResizeObserver
If you have legacy code:
const ro = new ResizeObserver(([entry]) => {
el.classList.toggle("is-wide", entry.contentRect.width >= 480);
});
ro.observe(el);
Replace with a wrapper + @container and delete the observer {Thay bằng wrapper + @container và xóa observer}.
Benefits: no FOUC on first paint, no SSR/client class mismatch, styles stay in CSS where designers and linters can see them {Lợi ích: không FOUC first paint, không mismatch class SSR/client, style ở CSS nơi designer và linter thấy được}.
9. Live demo
The demo below wraps a product card in a resizable query container (240px–700px) {Demo dưới bọc product card trong query container có thể đổi kích thước (240px–700px)}. Drag the handle or use the slider — the card switches compact → horizontal → expanded based on container width, while a live readout shows viewport width for contrast {Kéo handle hoặc slider — card chuyển compact → horizontal → expanded theo chiều rộng container, readout live hiện viewport width để đối chiếu}. Toggle cqi units to watch the title scale with the container instead of fixed rem steps {Bật cqi units để thấy title scale theo container thay vì bậc rem cố định}.
Open the full demo {Mở demo đầy đủ}: /tools/css-container-queries-demo/.
10. Decision checklist
Before you add container-type to a wrapper, run through this {Trước khi thêm container-type lên wrapper, chạy qua checklist}:
- Does this component appear in multiple width contexts on one page? {Component xuất hiện nhiều ngữ cảnh width trên một trang?}
- Is the query container an ancestor, not the element you’re styling? {Query container là ancestor, không phải element đang style?}
- Does the slot have a definite inline size (flex
min-width: 0, grid track, explicit width)? {Slot có inline size xác định (flexmin-width: 0, grid track, width explicit)?} - Did you start with
inline-sizebefore reaching forsize? {Bắt đầuinline-sizetrước khi dùngsize?} - Are named containers used when nesting query contexts? {Container có tên khi lồng query context?}
- Are global breakpoints still handled with
@media/prefers-*where appropriate? {Breakpoint toàn cục vẫn xử lý bằng@media/prefers-*khi phù hợp?} - Did you verify in DevTools which
@containerrules matched at narrow, mid, and wide slot sizes? {Đã verify trong DevTools rule@containernào khớp ở slot hẹp, giữa, rộng?}
Container queries do not kill media queries — they complete responsive design by making components honest about the space they inherit {Container query không giết media query — chúng hoàn thiện responsive design bằng cách component trung thực về không gian được kế thừa}. For senior teams, the win is fewer props, less resize JS, and layouts that survive sidebars, split views, and dense dashboards without forking components {Với team senior, lợi ích là ít prop, ít resize JS, layout sống sót sidebar, split view, dashboard dày mà không fork component}.