Skip to content

CSS Container Queries: Responsive Without Media Queries

How CSS container queries let components respond to their parent's size instead of the viewport — enabling truly reusable responsive components.

4 min read
Side-by-side comparison of a card component adapting its layout based on container width

Media queries respond to the viewport. Container queries respond to the parent element. This distinction changes how responsive components work. A card component in a sidebar should display differently than the same card component in a main content area — even though the viewport has not changed.

Media queries cannot express "if my parent is narrow, stack vertically." Container queries can. This is the missing piece that makes truly reusable responsive components possible.

The Problem with Media Queries for Components

Media queries couple component layout to the viewport. This works when every component lives at a predictable width, but breaks when components are reused in different containers.

csscss
/* ❌ Media query — tied to viewport width, not component context */
.product-card {
  display: grid;
  grid-template-columns: 1fr;
}
 
@media (min-width: 768px) {
  .product-card {
    grid-template-columns: 200px 1fr;
  }
}
 
/* Problem: the same card in a 300px sidebar still gets the
   horizontal layout at 768px viewport — it's too wide for the container */
csscss
/* ✅ Container query — responds to the actual available space */
.card-container {
  container-type: inline-size;
  container-name: card;
}
 
.product-card {
  display: grid;
  grid-template-columns: 1fr;
}
 
@container card (min-width: 400px) {
  .product-card {
    grid-template-columns: 200px 1fr;
  }
}
 
/* Now the card switches to horizontal layout only when
   its container gives it 400px+ of space */

The card in a narrow sidebar stays stacked. The same card in a wide content area goes horizontal. No JavaScript, no conditional classes, no duplicate components.

Setting Up Containment

Container queries require a containment context — telling the browser which element to measure. The container-type property establishes this.

csscss
/* Three containment types */
 
/* inline-size: respond to width changes (most common) */
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* size: respond to both width and height changes */
.dashboard-tile {
  container-type: size;
  container-name: tile;
}
 
/* normal: no containment (opt-out, default) */
.unrestricted {
  container-type: normal;
}
 
/* Shorthand: combined name and type */
.panel {
  container: panel / inline-size;
}
htmlhtml
<!-- The container is the parent, the query applies to children -->
<div class="sidebar">           <!-- containment context -->
  <div class="product-card">    <!-- this component adapts -->
    <img src="product.jpg" alt="Product photo" />
    <div class="product-info">
      <h3>Product Name</h3>
      <p>Description text here</p>
    </div>
  </div>
</div>

An element with container-type: inline-size tells the browser: "My children might query my width. Track it." Without this declaration, @container queries have no context to measure against.

Practical Component Patterns

Container queries shine for components that appear in multiple layout contexts.

csscss
/* Navigation component: horizontal in wide containers, vertical in narrow */
.nav-container {
  container: nav / inline-size;
}
 
.nav-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
 
.nav-item {
  padding: 8px 12px;
}
 
.nav-item .label {
  display: none;
}
 
@container nav (min-width: 200px) {
  .nav-item .label {
    display: inline;
  }
}
 
@container nav (min-width: 600px) {
  .nav-list {
    flex-direction: row;
    gap: 8px;
  }
}
csscss
/* Stats card: adapts from compact to full layout */
.stats-container {
  container: stats / inline-size;
}
 
.stat-card {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px;
}
 
.stat-card .chart {
  display: none;
}
 
.stat-card .trend {
  font-size: 12px;
}
 
@container stats (min-width: 300px) {
  .stat-card {
    flex-direction: column;
    align-items: flex-start;
    padding: 16px;
  }
 
  .stat-card .trend {
    font-size: 14px;
  }
}
 
@container stats (min-width: 500px) {
  .stat-card .chart {
    display: block;
    height: 80px;
  }
}

Container Query Units

Container queries introduce new CSS units relative to the container's dimensions. These units work inside @container blocks and anywhere a length value is accepted.

csscss
.card-wrapper {
  container: card / inline-size;
}
 
.card-title {
  /* cqw = 1% of container's inline size (width) */
  font-size: clamp(14px, 4cqw, 24px);
 
  /* cqh = 1% of container's block size (height) */
  /* cqi = 1% of container's inline size */
  /* cqb = 1% of container's block size */
  /* cqmin = smaller of cqi and cqb */
  /* cqmax = larger of cqi and cqb */
}
 
.card-image {
  /* Image height relative to container width */
  height: 50cqi;
  object-fit: cover;
}
 
@container card (min-width: 400px) {
  .card-title {
    font-size: clamp(16px, 3cqw, 28px);
  }
 
  .card-image {
    height: 30cqi;
  }
}

Container query units let typography and spacing scale relative to the component's container, not the viewport. A card in a narrow column gets proportionally smaller text without explicit breakpoints.

Combining with CSS Grid Layouts

Container queries work naturally with CSS Grid to create dashboard layouts where each tile adapts independently.

csscss
/* Dashboard grid — each tile is a container */
.dashboard {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 16px;
}
 
.dashboard-tile {
  container: tile / inline-size;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  overflow: hidden;
}
 
/* Tile content adapts to available space */
.tile-header {
  display: flex;
  justify-content: space-between;
  padding: 12px;
}
 
.tile-body {
  padding: 12px;
}
 
.tile-actions {
  display: none;
}
 
@container tile (min-width: 350px) {
  .tile-header {
    padding: 16px 20px;
  }
 
  .tile-body {
    padding: 16px 20px;
  }
 
  .tile-actions {
    display: flex;
    gap: 8px;
    padding: 12px 20px;
    border-top: 1px solid var(--border-color);
  }
}
htmlhtml
<div class="dashboard">
  <!-- Each tile adapts based on how many columns the grid assigns -->
  <div class="dashboard-tile">
    <div class="tile-header">
      <h3>Revenue</h3>
      <span class="badge">+12%</span>
    </div>
    <div class="tile-body">
      <span class="metric">$45,231</span>
    </div>
    <div class="tile-actions">
      <button>Details</button>
      <button>Export</button>
    </div>
  </div>
  <!-- More tiles... -->
</div>

When the grid assigns a tile a narrow column, tile-actions hides. When the tile gets a wider column, actions appear. No JavaScript, no resize observers.

Nesting Container Queries

Containers can be nested. A child queries its nearest ancestor with containment, not the outermost container.

csscss
.page-layout {
  container: page / inline-size;
}
 
.sidebar-panel {
  container: sidebar / inline-size;
}
 
/* This queries the sidebar container, not the page */
@container sidebar (min-width: 250px) {
  .sidebar-widget {
    padding: 16px;
  }
}
 
/* This queries the page container */
@container page (min-width: 1024px) {
  .main-content {
    max-width: 800px;
    margin: 0 auto;
  }
}
 
/* Named containers avoid ambiguity */
/* Without a name, @container queries the nearest ancestor container */
csscss
/* ❌ Ambiguous — which container does this query? */
@container (min-width: 400px) {
  .widget { /* ... */ }
}
 
/* ✅ Explicit — queries the named container */
@container sidebar (min-width: 400px) {
  .widget { /* ... */ }
}

Always name containers when nesting. Unnamed @container queries match the nearest ancestor container, which can produce unexpected results when the DOM structure changes.

Migration Strategy

You do not need to replace all media queries with container queries. Migrate component-level breakpoints while keeping page-level media queries.

csscss
/* Keep media queries for page layout */
@media (min-width: 768px) {
  .page-layout {
    display: grid;
    grid-template-columns: 250px 1fr;
  }
}
 
@media (min-width: 1200px) {
  .page-layout {
    grid-template-columns: 300px 1fr 250px;
  }
}
 
/* Use container queries for components inside the layout */
.main-content {
  container: main / inline-size;
}
 
.sidebar {
  container: sidebar / inline-size;
}
 
@container main (min-width: 600px) {
  .article-card {
    grid-template-columns: 150px 1fr;
  }
}
 
@container sidebar (min-width: 200px) {
  .sidebar-card .description {
    display: block;
  }
}

Media queries manage the page frame. Container queries manage the component internals. This division is clean and future-proof.

Key Takeaways

  1. Container queries decouple components from viewport — components respond to their actual available space
  2. Set container-type: inline-size on parent elements — this establishes the measurement context
  3. Name your containers — avoids ambiguity when nesting multiple containment contexts
  4. Use container query units (cqw, cqi) — for typography and spacing that scales with the component
  5. Keep media queries for page layout — migrate component-level breakpoints to container queries
  6. Combine with CSS Grid — auto-fit grids plus container queries create fully adaptive dashboards
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX