jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-typecontainer-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

  1. Why viewport media queries fail components
  2. The container query model
  3. container-type: inline-size, size, normal
  4. container-name and named @container rules
  5. Container query units (cqw, cqi, cqh, …)
  6. Style queries — @container style(...) in 2025–2026
  7. Gotchas, containment side effects, and debugging
  8. Real component: product card in a dashboard grid
  9. Live demo
  10. 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:

ApproachProblem {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" propEvery parent must know breakpoints; breaks encapsulation {Mọi parent phải biết breakpoint; phá encapsulation}
ResizeObserver + class toggling in JSLayout flash, hydration mismatch in SSR, test burden {Flash layout, hydration mismatch SSR, gánh nặng test}
@media tuned to “most common” viewportWrong 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}:

  1. Browser finds nearest qualifying ancestor container for each @container rule {Browser tìm ancestor container đủ điều kiện gần nhất cho mỗi rule @container}.
  2. If container-name is specified in the rule, it must match a named ancestor {Nếu rule chỉ định container-name, phải khớp ancestor có tên}.
  3. Size conditions (min-width, max-width, min-height, …) compare against the container’s query size — typically inline size when using inline-size containment {Điều kiện kích thước so với query size của container — thường là inline size khi dùng containment inline-size}.
  4. 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}.

ValueQueries availableContainment appliedTypical use
normalNone (default)NoneNon-container elements
inline-sizeWidth / inline axisSize containment on inline axisMost UI components — cards, alerts, nav items
sizeWidth and heightFull size containmentCharts, maps, fixed-aspect panels where height queries matter
inline-size size (shorthand)Both axes explicitlyCombinedRare; 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 size containment {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ưới size containment}.
  • 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-size only. Reach for size when 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ùng size khi 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 */
}
SyntaxMeaning
@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}.

UnitDefinition
cqw1% of query container width
cqh1% of query container height
cqi1% of query container inline size
cqb1% of query container block size
cqminmin(cqi, cqb)
cqmaxmax(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 @media stair-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-type on the wrapper, cqi falls 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ên container-type trên wrapper thì cqi fallback 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)

FeatureChromiumFirefoxSafari
@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}.

ConcernTool
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: size prevents the container’s block size from depending on descendant content height in the standard way. A container with size and no explicit height can collapse or clip unexpectedly. Prefer inline-size until you have a measured height strategy. {Tác dụng phụ containment: container-type: size ngăn block size container phụ thuộc chiều cao descendant theo cách thường. Container size không height explicit có thể sụp hoặc clip bất ngờ. Ưu tiên inline-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 (flex min-width: 0, grid track, width explicit)?}
  • Did you start with inline-size before reaching for size? {Bắt đầu inline-size trước khi dùng size?}
  • 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 @container rules matched at narrow, mid, and wide slot sizes? {Đã verify trong DevTools rule @container nà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}.