Responsive Component Patterns: Architecture & Implementation

Modern interface development demands a decisive shift away from rigid viewport breakpoints toward context-aware architectures. As component libraries scale, relying on global media queries creates brittle, tightly coupled styles that break when modules are placed in unpredictable parent contexts. This guide covers the architecture shared by every container-driven component — where the boundary goes, which axis to query, how to choose thresholds, and how the pieces are tested — building on the foundations in Mastering Container Queries & Responsive Layouts. The individual archetypes (cards, navigation, tables, sidebars, forms) each get their own build in the related guides at the end; this page is the framework they all share.

Container-driven layout switch The same card renders stacked in a narrow container and side-by-side in a wide container. Same component, container decides layout narrow container media text wide container media text

Prerequisites

  • @container syntax — the at-rule, size conditions, and named containers. The Container Query Syntax Basics guide is the reference.
  • Flexbox and Grid fluency — particularly grid-template-areas, minmax(), and how 1fr differs from auto.
  • Intrinsic sizingmin-content, max-content, fit-content, and why min-width: auto on a flex item causes overflow.
  • Custom properties — components here express their thresholds and spacing as tokens.
  • A working understanding of containmentcontain and what layout, style, size, and paint containment each promise.

Core concept: the component owns a size contract, not a breakpoint

A viewport breakpoint is a statement about the user's device. A container breakpoint is a statement about the component: below this inline size, my wide arrangement stops working. That difference is what makes the pattern composable. The same component can appear in a 320px sidebar, a 640px modal, and a 1100px main column, and each instance picks its own arrangement without any of them knowing where they are.

The specification mechanics behind this are narrow and worth stating precisely. container-type turns an element into a query container by giving it the relevant containment: inline-size applies inline-size containment plus layout, style, and paint containment; size applies containment on both axes. A @container rule then resolves against the nearest ancestor query container — the nearest named one if the rule names a container. Two consequences follow directly, and between them they explain most container query bugs:

An element can never query itself. The queried box is an ancestor, always. This is not an implementation shortcut; it is a circularity guard. A container's size may depend on its contents, so allowing a rule inside the container to change that size based on the size would never settle.

Establishing a container changes how that element sizes itself. Inline-size containment means the element's inline size no longer depends on its contents — it takes its size from its own parent's layout. container-type: size goes further and makes the block size independent too, which is why a plain container-type: size on a div with auto height collapses it to zero. The choice between the two is consequential enough to have its own treatment in container-type: size vs inline-size.

Everything else in this guide follows from those two facts.

The three-element skeleton

Every well-behaved container-driven component has the same shape:

<!-- 1. The boundary: owns containment, owns nothing else -->
<div class="cq-wrap">
  <!-- 2. The root: owns the layout that changes -->
  <div class="widget">
    <!-- 3. The parts: owned by the root's layout -->
    <div class="widget__a"></div>
    <div class="widget__b"></div>
  </div>
</div>
.cq-wrap {
  container-type: inline-size;
  container-name: widget;
  /* No padding, no border, no display change. The wrapper measures; it
     does not decorate. Anything you add here changes what is measured. */
}

.widget {
  display: grid;
  gap: var(--widget-gap, 1rem);
  grid-template-columns: 1fr; /* the narrow default */
}

@container widget (inline-size >= 34rem) {
  .widget {
    grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
  }
}

The separation looks like ceremony until the first time you need padding on the component. Put padding on the wrapper and every threshold you tuned is now measuring a different box than the one your content occupies. Keep the wrapper naked and the numbers stay honest.

Note minmax(0, 1fr) rather than 1fr. A grid track's default minimum is auto, which means "at least min-content" — a long unbroken string or a wide image will then push the track past its share and burst the container. Explicitly flooring it at 0 is the single most reliable habit in container-driven layout, and the preventing flex and grid overflow guide explains the underlying sizing rules.


Syntax and parameters

TokenAccepted valuesDefault
container-typenormal | inline-size | sizenormal
container-nameOne or more idents, space-separated; none to clearnone
container (shorthand)<name> / <type>none / normal
@container query targetAn optional container name, then a condition in parenthesesNearest ancestor query container
Size featureswidth, height, inline-size, block-size, aspect-ratio, orientation
Range syntax(inline-size >= 34rem), (20rem <= inline-size <= 40rem)
Logical operatorsand, or, not (no mixing and with or at one level without parentheses)
Style conditionstyle(--token: value) — custom properties only in current implementations
Container unitscqi, cqb, cqw, cqh, cqmin, cqmaxResolve against nearest query container on that axis

inline-size and block-size are the logical forms and are what you should write; width and height are the physical equivalents and behave identically in horizontal-tb writing modes but not in vertical ones. The range syntax (>=) is clearer than min-width for the same reason it is in media queries — you can express a band in one condition instead of two. Container units are covered in depth in container query units cqi and cqb explained.


Step-by-step implementation

Step 1 — Decide which axis actually drives the change

Before writing any query, name the thing that breaks. Almost always it is horizontal: two columns stop fitting, a label no longer sits beside its field, a row of actions wraps badly. That means inline-size, and inline-size is the cheap option — it leaves block sizing content-driven, so the component still grows to fit its text.

Query block-size only when vertical space genuinely dictates the design, and remember it requires container-type: size, which means you must give the container an explicit block size from outside. If you find yourself reaching for it, check whether the parent layout can express the constraint instead.

/* Overwhelmingly the right default */
.cq-wrap { container-type: inline-size; }

/* Only when the container's height is set by its parent, e.g. a grid row
   with a fixed track, and the content must respond to that height. */
.tile-wrap {
  block-size: 100%;
  container-type: size;
}

Step 2 — Name every container

An unnamed @container binds to the nearest ancestor query container, whatever that happens to be. That is fine in a demo and dangerous in a design system: the day someone wraps your component in another container, your rules silently start measuring the wrong box. Names cost one declaration and make the binding explicit.

.card-wrap    { container: card / inline-size; }
.sidebar-wrap { container: sidebar / inline-size; }
.form-wrap    { container: form / inline-size; }

/* Binds to the nearest ancestor named `card`, skipping any unnamed
   or differently-named containers in between. */
@container card (inline-size >= 30rem) { /* ... */ }

A container can carry several names (container-name: card layout;), which is useful when a component participates in more than one query vocabulary. Naming and nesting behaviour is detailed in nesting and naming container queries.

Step 3 — Derive thresholds from content, not from devices

This is the step people skip, and it is the one that makes container queries pay off. A viewport breakpoint at 768px is a guess about hardware. A container threshold should be a measurement: the width at which your two-column arrangement stops being readable.

The method is mechanical. Take the widest arrangement, add up its irreducible parts, and that sum is your threshold:

.media-object {
  /* Express the parts as tokens so the threshold has a derivation. */
  --thumb: 8rem;      /* the thumbnail never usefully shrinks below this */
  --gap: 1rem;
  --text-min: 20rem;  /* ~45-60 characters at this font size */

  display: grid;
  gap: var(--gap);
}

/* 8 + 1 + 20 = 29rem. Round up for breathing room. */
@container card (inline-size >= 30rem) {
  .media-object {
    grid-template-columns: var(--thumb) minmax(0, 1fr);
    align-items: start;
  }
}

Two rules of thumb keep the numbers stable. Use rem, not px, so a user who raises their base font size gets the stacked layout sooner rather than a cramped two-column one. And avoid more than two or three thresholds per component — if you need five, the arrangement is doing too much and probably wants splitting.

Step 4 — Write the narrow arrangement as the unconditional default

Every rule outside a @container block is the fallback for three separate situations at once: a narrow container, a browser that does not support the feature, and a context where nobody established a container at all. Write the single-column, stacked, full-width version first and let queries only add the wide arrangement.

/* Unconditional: works everywhere, including with no container present */
.panel {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md, 1rem);
}

.panel__aside { order: 2; }

/* Additive enhancement only */
@container panel (inline-size >= 42rem) {
  .panel {
    grid-template-columns: minmax(0, 1fr) minmax(0, 18rem);
  }
  .panel__aside { order: 0; }
}

Because the enhancement is purely additive, no @supports gate is needed for the layout to be usable in an engine without container queries — it simply stays stacked. When you do need the wide arrangement to reach those engines, the container query fallbacks guide covers how to mirror thresholds onto viewport queries without duplicating the design.

Step 5 — Scale the in-between with units, not more thresholds

Thresholds handle rearrangement. Everything continuous — padding, gaps, type size, image aspect — should scale smoothly with cqi inside a clamp(), so the component looks deliberate at every width rather than only at the widths you tested.

.panel {
  /* min for tiny containers, fluid middle, max so it stops growing */
  --pad: clamp(0.75rem, 4cqi, 2rem);
  --gap: clamp(0.5rem, 2cqi, 1.5rem);

  padding: var(--pad);
  gap: var(--gap);
}

.panel__title {
  font-size: clamp(1.05rem, 0.9rem + 1.6cqi, 1.75rem);
  text-wrap: balance;
}

Adding a rem component to the fluid middle term (0.9rem + 1.6cqi) rather than using a bare cqi value keeps the text responsive to browser zoom and user font-size settings; a pure container-unit font size ignores them. That accessibility constraint is the subject of fluid type accessibility and zoom, and the broader technique is covered in Fluid Typography with clamp().

Step 6 — Query state as well as size

Size is not the only thing a component's context can tell it. A style query reads a custom property from the container, which lets a parent set --density: compact or --theme: dark once and have every descendant respond without a class on each one.

.region { container-name: region; container-type: inline-size; }

@container region style(--density: compact) {
  .panel { --pad: 0.5rem; --gap: 0.375rem; }
  .panel__desc { display: none; }
}

Keep style queries pointed at deliberately-set, slow-changing tokens. Querying something that changes every frame turns a cheap style recalculation into a per-frame one. The style queries and container state guide covers the support picture and the patterns worth using.


Annotated production example

A metric tile — the unit a dashboard is built from. It has to work at 200px inside a compact grid cell and at 500px as a hero statistic, and it demonstrates the whole framework in one file: naked wrapper, additive queries, content-derived thresholds, fluid in-between, and a state query for density.

<div class="tile-wrap">
  <article class="tile">
    <p class="tile__label">Monthly recurring revenue</p>
    <p class="tile__value">$482,910</p>
    <p class="tile__delta tile__delta--up">
      <span aria-hidden="true">▲</span> 12.4%
      <span class="tile__period">vs. last month</span>
    </p>
    <div class="tile__spark" role="img" aria-label="Upward trend over 12 months"></div>
  </article>
</div>
.tile-wrap {
  /* Boundary only. No padding here — it would skew every threshold below. */
  container: tile / inline-size;
}

.tile {
  /* Thresholds and rhythm as tokens, so a variant can retune without
     touching a single rule. */
  --tile-pad: clamp(0.75rem, 5cqi, 1.5rem);
  --tile-gap: clamp(0.25rem, 1.5cqi, 0.75rem);

  display: grid;
  /* Narrow default: one column, spark hidden. Also the no-container and
     no-support rendering. */
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas:
    "label"
    "value"
    "delta";
  gap: var(--tile-gap);
  padding: var(--tile-pad);
  border: 1px solid var(--border-subtle, rgb(0 0 0 / 0.14));
  border-radius: 0.75rem;
  background: var(--surface-bg, #fff);
}

.tile__label {
  grid-area: label;
  margin: 0;
  font-size: clamp(0.75rem, 0.7rem + 0.5cqi, 0.875rem);
  letter-spacing: 0.02em;
  opacity: 0.72;
  /* At the narrowest sizes a long label must not force the tile wider. */
  overflow-wrap: anywhere;
}

.tile__value {
  grid-area: value;
  margin: 0;
  font-weight: 700;
  /* rem floor keeps it zoomable; cqi term makes it feel sized to the tile. */
  font-size: clamp(1.25rem, 1rem + 5cqi, 2.75rem);
  /* Tabular figures stop the number jittering when it updates. */
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
}

.tile__delta {
  grid-area: delta;
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 0.35em;
  font-size: clamp(0.8rem, 0.75rem + 0.6cqi, 1rem);
}

.tile__delta--up { color: var(--positive-fg, #15803d); }

/* The qualifier is the first thing to go when space is tight. */
.tile__period { display: none; opacity: 0.7; }

.tile__spark {
  grid-area: spark;
  display: none;
  min-inline-size: 0;      /* grid child must be allowed to shrink */
  block-size: 3rem;
  border-radius: 0.375rem;
  background: linear-gradient(
    to top right,
    color-mix(in srgb, var(--accent, #7aa2ff) 22%, transparent),
    transparent
  );
}

/* Threshold 1 — derived: value column ~11rem + gap 1rem + qualifier ~7rem.
   Below this, "vs. last month" competes with the number itself. */
@container tile (inline-size >= 19rem) {
  .tile__period { display: inline; }
}

/* Threshold 2 — derived: stats block ~14rem + gap 1rem + sparkline 7rem.
   Only now is there room to put a chart beside the numbers. */
@container tile (inline-size >= 22rem) {
  .tile {
    grid-template-columns: minmax(0, 1fr) minmax(0, 7rem);
    grid-template-areas:
      "label spark"
      "value spark"
      "delta spark";
    align-items: center;
  }
  .tile__spark { display: block; }
}

/* State, not size: a compact region tells its tiles to tighten up. */
@container tile style(--density: compact) {
  .tile { --tile-pad: 0.5rem; --tile-gap: 0.2rem; }
  .tile__spark { display: none; }
}

/* Motion is additive and opt-in. */
@media (prefers-reduced-motion: no-preference) {
  .tile {
    transition: border-color 200ms ease, box-shadow 200ms ease;
  }
  .tile:hover {
    border-color: var(--accent, #7aa2ff);
    box-shadow: 0 2px 10px rgb(0 0 0 / 0.08);
  }
}

Three details do the heavy lifting. grid-template-areas means the two arrangements differ by a template string rather than by moving elements — the DOM order, and therefore the reading and focus order, never changes. Both thresholds are annotated with the arithmetic that produced them, so the next person to touch the file can re-derive rather than guess. And the sparkline is display: none at narrow widths rather than shrunk, because a chart below about 7rem communicates nothing; deciding what to drop is as much a part of the pattern as deciding what to rearrange.


Performance and accessibility notes

Containment is the point, not a bonus. Setting container-type: inline-size already applies layout, style, and paint containment to that element. The browser can therefore treat the subtree as a sizing island: when the container's inline size does not change, nothing inside it can invalidate layout outside it. Adding contain: layout style on top of container-type is redundant — the value is already implied.

Query cost scales with container count, not rule count. Style recalculation for container queries is proportional to how many query containers must be re-evaluated when sizes change. A page with a container on every list item in a thousand-row list will feel it; a page with a container per card will not. If a virtualised list is janky, hoist the container up to the list and query it once.

Do not put container-type on a flex or grid item casually. Inline-size containment makes the element's inline size independent of its contents. On a flex item with flex-basis: auto, that removes the content-based basis and the item can end up far narrower than intended. Wrap it instead: the flex item stays a normal box, and the wrapper inside it establishes the container.

Rearrangement must not become reordering. Container queries make it trivially easy to move things visually. order, grid-row, and flex-direction: column-reverse all change the painted order while leaving the DOM order — and therefore the tab order and the screen reader order — untouched. WCAG 1.3.2 (Meaningful Sequence) and 2.4.3 (Focus Order) are both about that mismatch. Prefer grid-template-areas, which relocates elements to named slots without renumbering anything, and if you do reorder, tab through the result before shipping.

Never remove content to save space if it carries meaning. Hiding a decorative sparkline is fine. Hiding a column of a data table, or a label, is not — it changes what the component says. display: none also removes elements from the accessibility tree, so anything hidden is gone for screen reader users too, at every width.

Preserve target sizes across the switch. A compact arrangement is exactly where buttons get squeezed. WCAG 2.2 Success Criterion 2.5.8 asks for a 24×24 CSS pixel minimum; enforce it with min-block-size/min-inline-size on interactive parts so no threshold can violate it. The target size and pointer accessibility guide covers the measurement rules.


DevTools debugging workflow

  1. Confirm the container exists. Select the wrapper, open Computed, and check container-type. If it reads normal, the declaration lost the cascade or is on the wrong element — every "my query never fires" bug ends here about half the time.
  2. Use the container badge. Chrome and Edge show a container badge next to query containers in the Elements tree. Clicking it highlights the container and, when you select a descendant, shows which container a @container rule resolved against. Firefox's inspector marks containers similarly and lists the active queries in the Rules pane.
  3. Watch the query flip live. Select the wrapper and drag its inline size using the box-model editor, or resize a resizable ancestor. In the Styles pane, @container blocks grey out when their condition is false and light up when true — that is your fastest confirmation that a threshold sits where you think it does.
  4. Read the container units. Add a temporary declaration such as outline: 1px solid red; inside the query, or inspect a cqi-based value in Computed — computed values are resolved to pixels, so clamp(0.75rem, 5cqi, 1.5rem) shows you the actual padding at the current width.
  5. Check that the wrapper is measuring the box you think. If thresholds fire at surprising widths, select the wrapper and look at the box model diagram. Any padding or border on it means the queried inline size differs from the content width your thresholds were derived from.
  6. Verify order after every rearrangement. With the component in its wide arrangement, use the accessibility pane to read the tree order, then Tab through the interactive elements. If the visual and focus orders disagree, the layout is reordering rather than relocating.
  7. Profile with the Performance panel if resizing stutters. Record while dragging the container edge. Long recalculate style entries with a high element count point at too many query containers; long layout entries point at content-driven sizing inside the container that the containment cannot isolate.

Browser compatibility

FeatureChromeSafariFirefoxEdge
container-type: inline-size and @container105+16+110+105+
container-type: size105+16+110+105+
Container query units (cqi, cqb, cqmin)105+16+110+105+
Range syntax in container conditions105+16+110+105+
@container style() for custom properties111+18+Not yet supported111+
:has() (used by several component patterns)105+15.4+121+105+

Size container queries have been available in every major engine since early 2023, which puts them comfortably past the point where an additive enhancement needs guarding. Style queries are the outlier: Firefox has not shipped them, so treat any style() rule as strictly optional polish and make sure the component is complete without it.


Common pitfalls

PitfallRoot causeResolution
The query never matches at any widthThe rule is trying to query the element that declares container-type, or the nearest container is a different, unnamed oneMove container-type to a dedicated wrapper above the component root, and give every container a container-name so the binding is explicit
The container collapses to zero heightcontainer-type: size applies containment on both axes, so the element no longer sizes to its contentsUse inline-size unless block size genuinely drives the design; if size is required, set an explicit block size on the container from its parent
Content overflows the container in the wide arrangementGrid tracks and flex items default to a min-content minimum, so long words, code, and images push past their shareFloor tracks with minmax(0, 1fr) and add min-inline-size: 0 to flex children that contain unbreakable content
Thresholds fire at the wrong width after a redesignPadding or a border was added to the wrapper, so the queried box is no longer the content box the thresholds were derived fromKeep the wrapper free of box decoration; move all padding to the component root inside it
Screen reader order disagrees with the visual layoutThe wide arrangement uses order or explicit line placement to move elementsSwitch to grid-template-areas, which relocates elements into named slots without altering DOM order

FAQ

Should I replace all media queries with container queries? No. Media queries remain optimal for page-level layout shifts, viewport-dependent features (e.g., navigation bars, full-bleed sections), and device orientation changes. Container queries should be reserved for component-level adaptability within unpredictable parent contexts.

How do container queries impact Core Web Vitals? When implemented with proper containment (contain: layout style), container queries reduce layout thrashing and improve LCP/CLS scores by isolating component rendering from global reflows. The browser skips unnecessary subtree recalculations, leading to faster paint cycles.

Can I combine container queries with CSS Grid? Absolutely. Container queries and Grid are highly complementary. Use Grid for macro-layout structure and container queries to adjust component density, padding, and internal arrangement based on available track space. This combination eliminates the need for breakpoint-heavy grid templates.

Where should the container boundary go in a component? On a wrapper element that owns no layout of its own, wrapping the component root. The component root cannot query itself, and putting container-type on an element that also participates in a flex or grid layout risks collapsing its size.


Related articles

More pages in the same section.