This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Front-end development in 2026 demands components that respond to their surroundings, not just the browser window. CSS container queries and subgrid, now fully supported across all major browsers (container queries: Chrome 105+, Firefox 110+, Safari 16+; subgrid: Chrome 117+, Firefox 71+, Safari 16+), make this achievable without a single line of JavaScript. Together, they enable a component architecture where elements adapt their layout based on the available space of their parent container while maintaining precise grid alignment with sibling components. This article walks through the core syntax, combined patterns, and how to implement both features in production, building toward a fully functional dashboard layout.

Table of Contents

Why Viewport-Based Design Falls Short for Components

Media queries have driven responsive design for over a decade, but they carry a core limitation: they respond to the viewport, not to the container a component actually lives in. Consider a product card component that appears in a narrow sidebar, a mid-width main content area, and a full-width hero section. With media queries alone, the card has no awareness of its immediate context. A @media (min-width: 768px) rule fires identically whether the card sits in a 250px sidebar or a 900px content region.

Developers have historically worked around this with modifier classes, JavaScript-based resize observers, or duplicated component variants. Each approach breaks in its own way: a viewport breakpoint change can silently break every context the component appears in, requiring per-context regression testing.

Media queries have driven responsive design for over a decade, but they carry a core limitation: they respond to the viewport, not to the container a component actually lives in.

This tutorial builds a context-aware product card system that adapts its internal layout based on its container's dimensions while maintaining cross-component alignment through subgrid. The prerequisite knowledge assumes working familiarity with CSS Grid fundamentals and standard media query syntax.

Understanding Container Queries: Core Concepts and Syntax

Defining a Containment Context

Container queries require an explicit containment context on an ancestor element. The container-type property establishes this context. Use inline-size to query the container's inline dimension (typically width in horizontal writing modes). Use size to query both inline and block dimensions, though this imposes stricter containment and should only be used when you genuinely need block-dimension queries. The default value, normal, establishes no containment context for dimensional queries.

The container-name property assigns a name to the containment context, enabling targeted queries when multiple containers are nested. A shorthand container property combines both: container: <name> / <type>.

.card-wrapper {
  container: card-container / inline-size;
}

This declares .card-wrapper as a named containment context called card-container, queryable along its inline size. The containment context must be set on an ancestor of the element being styled, never on the element querying its own size.

Writing @container Rules

The @container rule mirrors @media syntax but evaluates against the nearest containment context rather than the viewport. Named container queries target a specific ancestor by name, while unnamed queries resolve against the nearest ancestor with a valid container-type.

Container query length units provide measurements relative to the container: cqw (1% of container width), cqh (1% of container height), cqi (1% of container inline size), and cqb (1% of container block size). The spec also defines cqmin (the smaller of cqi and cqb) and cqmax (the larger), useful for aspect-ratio-independent container-relative sizing. These units replace viewport units inside component styles for truly context-relative sizing.

.product-card {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.75rem;
}

.product-card .card-image {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

@container card-container (min-width: 400px) {
  .product-card {
    grid-template-columns: 200px 1fr;
    grid-template-rows: auto auto;
  }

  .product-card .card-image {
    grid-row: 1 / span 2;
    aspect-ratio: 1;
  }
}

Below 400px of container width, the card stacks vertically with the image on top. At 400px and above, it switches to a side-by-side layout with the image spanning both rows on the left.

Container Query Gotchas and Constraints

A component cannot query its own size. This is the containment paradox: if an element's styles depended on its own dimensions, changing those styles would change its dimensions, creating a circular dependency. The containment context must always be an ancestor.

Setting container-type: inline-size removes the element from intrinsic sizing calculations along that axis. The container no longer sizes itself based on its content's preferred width. This works for block-level wrappers that fill their available space, but breaks for inline elements or flex items that rely on shrink-to-fit width.

For inline-size containment, layout cost is negligible in tested scenarios. container-type: size imposes both inline and block containment, which means the element cannot derive its height from its content either. This forces the browser to skip content-based height calculations and triggers additional layout recalculation when content changes dynamically. Avoid size unless block-dimension queries are genuinely needed.

Understanding Subgrid: Aligning Nested Elements to Parent Grids

The Problem Subgrid Solves

When a grid item becomes a grid container itself, its inner grid operates independently. In a row of three cards, each card's internal grid defines its own row tracks based on its content. A card with a long title pushes its description and button down relative to cards with shorter titles. The visual result is misaligned content rows across cards in the same grid row, a problem that no amount of fixed heights solves for dynamic content.

Subgrid Syntax and Behavior

Subgrid allows a grid item to adopt its parent grid's track definitions for its own rows, columns, or both. The syntax replaces the track listing with the keyword subgrid: grid-template-rows: subgrid or grid-template-columns: subgrid.

For subgrid to function, the child element must be both a grid item (placed on the parent grid) and a grid container. It must span the correct number of parent tracks, as it adopts exactly the tracks it spans. The subgrid can reference named lines from the parent grid (e.g., grid-row: card-start / card-end; works without additional syntax). On any subgridded axis, the parent grid controls gap; the browser silently ignores a local gap declaration on the subgrid element for that axis. Gap can only be set locally for a non-subgridded axis.

<div class="product-grid">
  <article class="product-card">
    <img src="product-1.jpg" alt="Product 1" class="card-image">
    <h3 class="card-title">Wireless Headphones</h3>
    <p class="card-description">Noise-canceling with 30-hour battery life and premium drivers.</p>
    <button class="card-cta">Add to Cart</button>
  </article>
  <article class="product-card">
    <img src="product-2.jpg" alt="Product 2" class="card-image">
    <h3 class="card-title">USB-C Hub</h3>
    <p class="card-description">7-in-1 adapter.</p>
    <button class="card-cta">Add to Cart</button>
  </article>
  <article class="product-card">
    <img src="product-3.jpg" alt="Product 3" class="card-image">
    <h3 class="card-title">Mechanical Keyboard with Hot-Swappable Switches</h3>
    <p class="card-description">Compact 75% layout with RGB backlighting and PBT keycaps.</p>
    <button class="card-cta">Add to Cart</button>
  </article>
</div>
.product-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: auto auto auto auto;
  gap: 1.5rem;
}

.product-card {
  display: grid;
  grid-row: span 4;
  grid-template-rows: subgrid;
  /* gap applies only to non-subgridded axes; row spacing is controlled by parent's gap */
}

.card-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 4px;
}

.card-cta {
  align-self: start;
}

Each card spans four parent row tracks and adopts them via subgrid. The image, title, description, and CTA button now align horizontally across all three cards regardless of content length. The longest title in any card determines the title row height for the entire row of cards.

Combining Container Queries and Subgrid: The Architecture Pattern

Why the Combination Is Powerful

Container queries and subgrid solve orthogonal problems. Container queries handle responsive adaptation: restructuring a component's layout based on available space. Subgrid handles cross-component alignment: ensuring consistent visual rhythm among sibling components. Used independently, each is valuable. Used together, they enable components that restructure themselves based on context while maintaining alignment with their siblings, a pattern that previously required JavaScript layout calculations.

Container queries and subgrid solve orthogonal problems. Container queries handle responsive adaptation: restructuring a component's layout based on available space. Subgrid handles cross-component alignment: ensuring consistent visual rhythm among sibling components.

Building the Product Card System: Step by Step

Step 1: HTML Structure

The HTML follows a straightforward pattern: a grid container holds multiple product cards, each with structured internal content.

<section class="product-listing">
  <div class="card-wrapper">
    <article class="product-card">
      <img src="headphones.jpg" alt="Wireless Headphones" class="card-image">
      <h3 class="card-title">Wireless Headphones</h3>
      <span class="card-price">$89.99</span>
      <div class="card-rating"><span aria-label="4.2 out of 5 stars">★★★★☆</span> (4.2)</div>
      <button class="card-action">Add to Cart</button>
    </article>
  </div>
  <div class="card-wrapper">
    <article class="product-card">
      <img src="keyboard.jpg" alt="Mechanical Keyboard" class="card-image">
      <h3 class="card-title">Mechanical Keyboard</h3>
      <span class="card-price">$124.50</span>
      <div class="card-rating"><span aria-label="4.8 out of 5 stars">★★★★★</span> (4.8)</div>
      <button class="card-action">Add to Cart</button>
    </article>
  </div>
  <div class="card-wrapper">
    <article class="product-card">
      <img src="mouse.jpg" alt="Ergonomic Mouse" class="card-image">
      <h3 class="card-title">Ergonomic Mouse</h3>
      <span class="card-price">$59.00</span>
      <div class="card-rating"><span aria-label="4.0 out of 5 stars">★★★★☆</span> (4.0)</div>
      <button class="card-action">Add to Cart</button>
    </article>
  </div>
</section>

Note the .card-wrapper element around each card. This is the containment context. The card itself cannot be both the queried container and the element whose styles change in response.

Step 2: Parent Grid and Subgrid Setup

.product-listing {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  /* Use grid-auto-rows instead of grid-template-rows to avoid row-bleeding
     when auto-fit reflows items into multiple implicit rows. */
  grid-auto-rows: auto;
  gap: 1.5rem;
}

.card-wrapper {
  display: grid;
  grid-row: span 5;
  grid-template-rows: subgrid;
  container: card-container / inline-size;
}

.product-card {
  display: grid;
  grid-row: span 5;
  grid-template-rows: subgrid;
  /* gap applies only to non-subgridded axes; row spacing is controlled by .product-listing's gap */
  padding: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}

The .card-wrapper does double duty: it participates in the parent grid as a subgrid (spanning five row tracks for image, title, price, rating, and action) and establishes a containment context for its child card. The .product-card itself also declares subgrid, inheriting the tracks through the wrapper.

Step 3: Adding Container Query Responsiveness

/* Default: compact/stacked layout for containers under 300px */
.card-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 4px;
}

/* Base font-size (fallback — no cqi) */
.card-title {
  font-size: 1rem;
}

/* Progressive enhancement — cqi only when container-type is active */
@supports (container-type: inline-size) {
  .card-title {
    font-size: clamp(0.9rem, 3cqi, 1.25rem);
  }
}

.card-price {
  font-weight: 700;
  color: #1a7f37;
}

.card-action {
  padding: 0.5rem 1rem;
  border: none;
  background: var(--color-action, #0066cc);
  color: white;
  border-radius: 4px;
  cursor: pointer;
}

/* Standard layout: 300px – 500px */
@container card-container (min-width: 300px) {
  .card-title {
    font-size: 1.15rem;
  }

  .card-rating {
    font-size: 0.9rem;
  }
}

/* Expanded/horizontal layout: above 500px */
@container card-container (min-width: 500px) {
  .product-card {
    grid-template-columns: 200px 1fr;
    grid-template-rows: subgrid;
  }

  .card-image {
    grid-row: 1 / span 5;
    grid-column: 1;
    aspect-ratio: 1;
    /* Use aspect-ratio alone to control image proportions;
       avoid combining with height: 100% in subgrid row contexts,
       as the constraints conflict when row heights are externally determined. */
    object-fit: cover;
  }

  .card-title,
  .card-price,
  .card-rating,
  .card-action {
    grid-column: 2;
  }
}

Below 300px, the card stacks vertically. Between 300px and 500px, it remains stacked with a larger title font-size (1.15rem) and smaller rating text (0.9rem). Above 500px, the card shifts to a horizontal layout with the image spanning all rows on the left. Subgrid alignment is maintained at this stage because the card still spans five parent rows; only the internal column structure changes. Step 4 describes a further refinement that reduces the span when the horizontal layout condenses content into fewer rows.

Step 4: Handling the Edge Cases

When the available space is wide enough to switch to a horizontal layout, you may want to condense content into fewer rows (for instance, collapsing rating and price onto the same visual line). Because a container cannot restyle itself in response to its own @container query (the containment paradox), the span change on .card-wrapper must be driven by a @media query or another mechanism at the parent-grid level. The @container rule is then used only for visual styling of the card's children.

The following replaces the 500px block from Step 3 entirely. Remove the previous @container card-container (min-width: 500px) rule before adding this one.

/* Drive the span change from a media query at the parent-grid level,
   since .card-wrapper IS the card-container source and cannot restyle itself
   via @container card-container. */
@media (min-width: 500px) {
  .card-wrapper {
    grid-row: span 3;
    grid-template-rows: subgrid;
  }

  .product-card {
    grid-row: span 3;
    grid-template-rows: subgrid;
    grid-template-columns: 180px 1fr;
  }
}

/* Keep visual style changes inside @container where they belong */
@container card-container (min-width: 500px) {
  .card-image {
    grid-row: 1 / span 3;
    grid-column: 1;
    aspect-ratio: 1;
    object-fit: cover;
  }

  .card-title,
  .card-action {
    grid-column: 2;
  }

  .card-price,
  .card-rating {
    grid-column: 2;
    grid-row: 2;
    /* Both placed on grid-row 2; the second item auto-places to the next track.
       For true same-row collapsing, wrap price and rating in a single container div. */
  }
}

/* Fallback for mixed contexts where some cards are horizontal, others vertical */
@supports not (grid-template-rows: subgrid) {
  .product-card {
    display: flex;
    flex-direction: column;
  }
}

Use display: contents sparingly when an intermediate wrapper needs to be transparent to the grid. However, screen readers may strip the semantic role of elements using display: contents. This issue persists across some browser and assistive technology combinations (notably VoiceOver on Safari and certain NVDA configurations). Testing with VoiceOver, NVDA, and JAWS is required when applying it to semantic elements like article or section.

Real-World Application: A Dashboard Layout

The Scenario

A dashboard with a collapsible sidebar, a main content area, and a widget panel uses the same stat card component across all three regions. Each panel has a different available width, and panels may be resized by the user.

Implementation Walkthrough

<div class="dashboard">
  <aside class="sidebar">
    <div class="stat-wrapper">
      <div class="stat-card">
        <span class="stat-icon">📈</span>
        <span class="stat-number">1,247</span>
        <span class="stat-label">Active Users</span>
        <div class="stat-sparkline">[sparkline placeholder]</div>
      </div>
    </div>
  </aside>
  <main class="main-content">
    <div class="stats-grid">
      <div class="stat-wrapper">
        <div class="stat-card">
          <span class="stat-icon">💰</span>
          <span class="stat-number">$34,500</span>
          <span class="stat-label">Revenue</span>
          <div class="stat-sparkline">[sparkline placeholder]</div>
        </div>
      </div>
      <div class="stat-wrapper">
        <div class="stat-card">
          <span class="stat-icon">📦</span>
          <span class="stat-number">892</span>
          <span class="stat-label">Orders</span>
          <div class="stat-sparkline">[sparkline placeholder]</div>
        </div>
      </div>
    </div>
  </main>
  <aside class="widget-panel">
    <div class="stat-wrapper">
      <div class="stat-card">
        <span class="stat-icon"></span>
        <span class="stat-number">99.8%</span>
        <span class="stat-label">Uptime</span>
        <div class="stat-sparkline">[sparkline placeholder]</div>
      </div>
    </div>
  </aside>
</div>
.dashboard {
  display: grid;
  grid-template-columns: minmax(150px, 200px) 1fr minmax(200px, 280px);
  min-height: 100vh;
  gap: 1rem;
  padding: 1rem;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  grid-auto-rows: auto;
  gap: 1rem;
}

.stat-wrapper {
  container: stat-panel / inline-size;
  display: grid;
  grid-row: span 4;
  grid-template-rows: subgrid;
}

.stat-card {
  display: grid;
  grid-row: span 4;
  grid-template-rows: subgrid;
  padding: 1rem;
  background: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #dee2e6;
  /* gap applies only to non-subgridded axes; row spacing is controlled by parent's gap */
}

/* Default: show label, hide sparkline.
   Note: if @container is unsupported, the label remains visible as a safe default. */
.stat-sparkline {
  display: none;
}

.stat-icon {
  font-size: 1.5rem;
}

.stat-number {
  font-size: 1.25rem;
  font-weight: 700;
}

.stat-label {
  font-size: 0.85rem;
  color: #6c757d;
}

/* Compact: icon + number only (narrow containers below 220px)
   upper bound: 220px - 1px to avoid overlap with next breakpoint */
@container stat-panel (max-width: 219px) {
  .stat-label {
    display: none;
  }
}

/* Expanded: icon + number + label + sparkline */
@container stat-panel (min-width: 300px) {
  .stat-sparkline {
    display: block;
    height: 40px;
    background: #e9ecef;
    border-radius: 4px;
    font-size: 0.75rem;
    line-height: 40px;
    text-align: center;
    color: #adb5bd;
  }

  .stat-card {
    grid-template-columns: auto 1fr;
  }

  .stat-icon {
    grid-row: 1 / 3;
    font-size: 2rem;
    align-self: center;
  }
}

In the sidebar (approximately 200px wide), the label is visible because the container exceeds the 220px compact threshold only when the sidebar has sufficient padding-free space. In practice, with padding: 1rem on the dashboard reducing the sidebar's content area, the stat-wrapper may fall below 220px, hiding the label. Test actual computed widths in DevTools to confirm which breakpoint fires at your sidebar width. In the 280px widget panel, the label appears. In the main content area with wider columns, the sparkline placeholder becomes visible and the card adopts a two-column internal layout. The same component, zero JavaScript, three distinct presentations.

The same component, zero JavaScript, three distinct presentations.

Testing Responsiveness Without Viewport Changes

Chrome DevTools (105+) supports container query debugging by displaying container overlays when inspecting elements with container-type set. Hovering over a @container rule in the Styles panel highlights the matched container. Firefox DevTools (110+) provides similar container query overlay features. Both tools let developers resize individual panels or containers by manipulating element dimensions directly, verifying that component adaptation triggers at the correct container widths rather than requiring viewport resizing.

Implementation Checklist and Best Practices

Container Queries Checklist

  • Define the containment context on the correct ancestor, not the component itself.
  • Set container-type to inline-size unless block-direction queries are specifically needed; avoid size by default.
  • Use named containers when multiple containment contexts are nested.
  • Replace viewport units with container query units (cqw, cqh, cqi, cqb, cqmin, cqmax) inside components.
  • A fallback layout outside @container rules ensures progressive enhancement.
  • Do not rely on intrinsic height of contained elements for layout calculations.

Subgrid Checklist

  • The child element must be both a grid item and a grid container.
  • Declare grid-template-rows: subgrid or grid-template-columns: subgrid (not both unless the item must align on both axes simultaneously).
  • Verify the child spans the correct number of parent tracks.
  • Reference named lines from the parent for clarity.
  • The parent grid controls gap on subgridded axes; set local gap only for non-subgridded axes.

Combined Pattern Checklist

  • Test container query breakpoints against actual container sizes, not viewport widths.
  • Update subgrid row/column spans when container queries change component structure.
  • Verify accessibility of display: contents across browser/AT combinations before using it on semantic elements.
  • Test components in at least three different container width contexts.
  • Use DevTools container query overlays for debugging.

Browser Support and Progressive Enhancement

Container queries (container-type, @container) and subgrid (grid-template-rows: subgrid, grid-template-columns: subgrid) ship in all modern browsers as of 2026 (container queries: Chrome 105+, Firefox 110+, Safari 16+; subgrid: Chrome 117+, Firefox 71+, Safari 16+). For projects requiring graceful degradation in older environments, @supports returns a binary match with no false positives in current engines.

/* Fallback: only when subgrid is NOT supported */
@supports not (grid-template-rows: subgrid) {
  .product-card {
    display: grid;
    grid-template-rows: auto auto auto auto auto;
    gap: 0.5rem;
  }

  @media (min-width: 768px) {
    .product-card {
      grid-template-columns: 200px 1fr;
    }
  }
}

/* Enhanced: only when both features are supported */
@supports (container-type: inline-size) and (grid-template-rows: subgrid) {
  .card-wrapper {
    container: card-container / inline-size;
    display: grid;
    grid-row: span 5;
    grid-template-rows: subgrid;
  }

  .product-card {
    grid-template-rows: subgrid;
    grid-row: span 5;
  }
}

The fallback provides a functional layout using viewport-based media queries and independent grid tracks. When both features are supported, the enhanced layer takes over with container-relative adaptation and cross-component alignment.

Thinking in Container-First Component Design

Moving from viewport-first to container-first design changes how you structure CSS component architecture. Instead of scattering @media breakpoints that assume a single layout context, you co-locate adaptation logic with the component itself. In my assessment, container queries and subgrid together close the last major gap in CSS component portability. The CSS Working Group is actively specifying container queries for style values (@container style(...)) and additional container query types, which will extend this pattern further.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.