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.

CSS layout has undergone three distinct evolutionary leaps over the past eight years. Together, CSS Grid, Subgrid, and Container Queries replace JavaScript-dependent layout workarounds with pure CSS. This tutorial walks through building a responsive card grid component that uses all three features, first in plain HTML and CSS, then integrated into a React component.

Table of Contents

The Layout Trilogy Is Finally Complete

CSS layout has undergone three distinct evolutionary leaps over the past eight years. CSS Grid landed in browsers in 2017, giving developers a true two-dimensional layout engine for the first time. Subgrid followed, with Firefox shipping support in version 71 (December 2019) and Chrome and Edge adding support in version 117 (August 2023). Once all major browsers supported the feature, Subgrid reached Baseline Newly Available status in late 2023. Container queries, including container-type, @container rules, and the associated cqw/cqh/cqi/cqb units, reached full cross-browser stability (Chrome 105, Firefox 110, Safari 16, Edge 105), completing what can reasonably be called the layout trilogy.

Together, these three features replace JavaScript-dependent layout workarounds with pure CSS. Grid handles macro layout. Subgrid enforces alignment consistency across sibling components. Container queries make individual components responsive to their own container rather than the viewport. Layouts that previously demanded JavaScript resize observers or fragile media query workarounds can now live entirely in stylesheets.

Grid handles macro layout. Subgrid enforces alignment consistency across sibling components. Container queries make individual components responsive to their own container rather than the viewport.

This tutorial walks through building a responsive card grid component that uses all three features, first in plain HTML and CSS, then integrated into a React component. Intermediate CSS knowledge is assumed. Grid basics will get a brief refresher, not a ground-up introduction.

Quick Refresher: Where Each Feature Fits

CSS Grid: The Macro Layout Engine

CSS Grid provides two-dimensional control over page and section layout through properties like grid-template-columns, grid-template-rows, and gap. It handles the outermost structural concerns: how many columns a layout uses, how tracks size themselves relative to available space, and how items flow into implicit or explicit tracks. Grid is the foundation the other two features build on.

Subgrid: Aligning Children to a Parent Grid

A familiar problem shows up the moment you nest grids: cards in a row with different content lengths end up with misaligned headings, body sections, and footers. By default, a grid item that is itself a grid container sizes its internal tracks independently of the parent. Subgrid fixes this. The key syntax is grid-template-columns: subgrid and grid-template-rows: subgrid, which tell the child to adopt the parent's column or row tracks rather than defining its own.

Container Queries: Component-Level Responsiveness

Media queries respond to the viewport. Container queries respond to the size of a parent container, making components truly self-contained and context-aware. The core syntax involves declaring a containment context with container-type (typically inline-size), optionally naming it with container-name, and then writing @container rules that fire based on the container's dimensions. Container query units (cqw, cqh, cqi, cqb) allow fluid sizing relative to the container rather than the viewport.

Browser Support in 2025: The Green Board

Current Support Matrix

FeatureChromeFirefoxSafariEdge
CSS Grid57+52+10.1+16+
Subgrid117+71+16+117+
Container Queries105+110+16+105+
CQ Units (cqw/cqh)105+110+16+105+

Note that Firefox shipped Subgrid significantly earlier (v71, December 2019) than Chrome and Edge (v117, August 2023). The "Baseline 2023" designation reflects the point when all major engines supported the feature, not any single engine's ship date.

For any project targeting the last two versions of major browsers, no polyfills are needed for any of these features. The entire trilogy is green across the board.

For teams that still need a progressive enhancement safety net, @supports requires no external dependencies and degrades silently in unsupporting browsers. Wrapping subgrid or container query rules in @supports (grid-template-rows: subgrid) or @supports (container-type: inline-size) blocks ensures graceful fallback without relying on third-party polyfill scripts. Note that @supports detects individual feature availability but cannot detect interaction issues between combined features. Test the combined implementation in your target browsers.

Building the Trilogy Card Grid: Step by Step

Step 1: Setting Up the Outer Grid

The outer grid handles macro layout, determining how many cards appear per row and how they reflow as the viewport changes. The repeat(auto-fill, minmax(280px, 1fr)) pattern creates responsive columns without any media queries: columns are at least 280px wide, expand to fill available space, and the browser creates as many as will fit.

Replace image-1.jpg, image-2.jpg, and image-3.jpg with real image paths or a placeholder service such as https://picsum.photos/400/200.

<div class="card-grid">
  <article class="card">
    <div class="card-inner">
      <img src="image-1.jpg" alt="Feature one" />
      <h3>Card Heading One</h3>
      <p>Short body text for this card.</p>
      <a href="#" class="cta">Learn More</a> <!-- Replace href="#" with actual destination URLs -->
    </div>
  </article>

  <article class="card">
    <div class="card-inner">
      <img src="image-2.jpg" alt="Feature two" />
      <h3>Card Heading Two</h3>
      <p>This card has significantly more body text to demonstrate alignment challenges across siblings.</p>
      <a href="#" class="cta">Learn More</a>
    </div>
  </article>

  <article class="card">
    <div class="card-inner">
      <img src="image-3.jpg" alt="Feature three" />
      <h3>Card Heading Three</h3>
      <p>Medium length text here.</p>
      <a href="#" class="cta">Learn More</a>
    </div>
  </article>
</div>
.card-grid {
  --card-rows: 4;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
  padding: 1.5rem;
}

This gives a fully responsive macro layout with implicit track creation. Cards reflow from three columns on wide screens down to a single column on narrow ones, all without breakpoints.

Step 2: Adding Subgrid for Consistent Card Internals

Without subgrid, each card's internal rows are sized independently. A card with a short heading and a card with a two-line heading will have their body text and CTAs at different vertical positions. This misalignment is one of the most common visual bugs in card-based layouts.

The fix: make each card a grid item that spans a defined number of rows, then apply grid-template-rows: subgrid so its internal elements lock to the parent grid's row tracks.

Important: container-type must not be applied to the same element that uses grid-template-rows: subgrid. Applying container-type to a subgrid participant establishes size containment on that element, which prevents the parent grid's row track sizing from propagating into the child, breaking subgrid alignment entirely. Instead, place container-type on an inner child element (.card-inner in this tutorial) to preserve subgrid track inheritance. This architectural choice is reflected in the HTML structure above.

.card {
  display: grid;
  grid-row: span var(--card-rows);
  grid-template-rows: subgrid;
  border: 1px solid var(--card-border-color, #e0e0e0);
  border-radius: 8px;
  overflow: hidden;
}

.card-inner {
  display: grid;
  grid-row: 1 / -1;
  grid-template-rows: subgrid;
}

.card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.card h3 {
  padding: 0 1rem;
  margin: 0;
}

.card p {
  padding: 0 1rem;
  margin: 0;
}

.card .cta {
  padding: 0 1rem 1rem;
  align-self: end;
}

The grid-row: span var(--card-rows) declaration is critical. It tells the parent grid that each card occupies four row tracks (image, heading, body, CTA). The --card-rows custom property lives on .card-grid so the span value and HTML child count stay coupled in one place. Then grid-template-rows: subgrid distributes the card's children across those four parent-defined tracks. All sibling cards share the same track sizing, so headings align with headings, body text with body text, and CTAs sit at a consistent baseline regardless of content length. The span value must match the number of direct grid children in each card. Cards with different child counts will misalign.

Note: When grid-template-rows: subgrid is active, the child element's row-gap is ignored. Spacing between card sections is controlled by the parent .card-grid gap value. Use padding on individual card children if you need different internal spacing.

Step 3: Making Cards Container-Query-Aware

With the macro grid and subgrid alignment in place, container queries add the final layer: each card can adapt its own internal layout based on how much space it actually occupies. The container-type declaration goes on .card-inner, not on .card itself, to avoid breaking subgrid track inheritance.

.card-inner {
  container-type: inline-size;
  container-name: card;
}

@container card (min-inline-size: 480px) {
  .card-inner {
    grid-template-columns: 200px 1fr;
  }

  .card-inner img {
    height: 100%;
    grid-row: 1 / -1;
  }

  .card-inner h3 {
    font-size: clamp(1.25rem, 3cqi, 2rem);
  }
}

@container card (max-inline-size: 479.999px) {
  .card-inner h3 {
    font-size: clamp(1rem, 5cqi, 1.5rem);
  }
}

When a card's inline size exceeds 480px (which happens when the outer grid gives it enough room), the layout switches from a vertical stack to a horizontal arrangement with the image on the left and text on the right. The cqi unit sizes the heading relative to the card container's inline dimension, keeping typography proportional. Both the narrow and wide rules use clamp() to ensure headings have a minimum floor and maximum ceiling, preventing accessibility issues at extreme container sizes. The max-inline-size: 479.999px value ensures there is no fractional-pixel gap between the two breakpoints.

Using container-name: card makes the query explicit about which containment context it targets. This becomes essential when containers are nested, preventing queries from accidentally matching an ancestor container.

Step 4: The Combined Result

Here is the complete, unified implementation combining all three features:

<div class="card-grid">
  <article class="card">
    <div class="card-inner">
      <img src="image-1.jpg" alt="Feature one" />
      <h3>Card Heading One</h3>
      <p>Short body text for this card.</p>
      <a href="#" class="cta">Learn More</a>
    </div>
  </article>

  <article class="card">
    <div class="card-inner">
      <img src="image-2.jpg" alt="Feature two" />
      <h3>Card Heading Two</h3>
      <p>This card has significantly more body text to demonstrate alignment challenges across siblings.</p>
      <a href="#" class="cta">Learn More</a>
    </div>
  </article>

  <article class="card">
    <div class="card-inner">
      <img src="image-3.jpg" alt="Feature three" />
      <h3>Card Heading Three</h3>
      <p>Medium length text here.</p>
      <a href="#" class="cta">Learn More</a>
    </div>
  </article>
</div>
.card-grid {
  --card-rows: 4;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
  padding: 1.5rem;
}

.card {
  display: grid;
  grid-row: span var(--card-rows);
  grid-template-rows: subgrid;
  border: 1px solid var(--card-border-color, #e0e0e0);
  border-radius: 8px;
  overflow: hidden;
}

.card-inner {
  display: grid;
  grid-row: 1 / -1;
  grid-template-rows: subgrid;
  container-type: inline-size;
  container-name: card;
}

.card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.card h3 {
  padding: 0 1rem;
  margin: 0;
  font-size: clamp(1rem, 4vw, 1.5rem);
}

.card p {
  padding: 0 1rem;
  margin: 0;
}

.card .cta {
  padding: 0 1rem 1rem;
  align-self: end;
}

@container card (min-inline-size: 480px) {
  .card-inner {
    grid-template-columns: 200px 1fr;
  }

  .card-inner img {
    height: 100%;
    grid-row: 1 / -1;
  }

  .card-inner h3 {
    font-size: clamp(1.25rem, 3cqi, 2rem);
  }
}

@container card (max-inline-size: 479.999px) {
  .card-inner h3 {
    font-size: clamp(1rem, 5cqi, 1.5rem);
  }
}

Resizing the browser triggers a cascade: the outer grid reflows its columns, which changes each card's available inline size, which fires the container queries, which restructure the card's internals, while subgrid alignment holds steady across sibling cards throughout the entire process.

Resizing the browser triggers a cascade: the outer grid reflows its columns, which changes each card's available inline size, which fires the container queries, which restructure the card's internals, while subgrid alignment holds steady across sibling cards throughout the entire process.

The base .card h3 font-size uses viewport-relative vw units rather than cqi units. The cqi unit is only valid inside @container blocks where the containment context is guaranteed to be established. Outside those blocks, cqi may resolve unpredictably. The @container rules override the base value with cqi-based sizing once containment is confirmed.

Integrating the Trilogy in a React Component

Component Architecture

The React implementation uses a CardGrid wrapper component and a Card child component. The critical architectural decision: all layout logic lives in CSS. React handles data mapping and rendering only. No JavaScript-based resize observers are needed for the responsive behavior.

This implementation requires React 17+ with the automatic JSX transform enabled. For React versions below 17, add import React from 'react'; at the top of the component file. CSS Modules support is required from your bundler (Vite, Next.js, and Create React App all support this out of the box).

When You Still Need JavaScript

Container queries eliminate most resize-observer use cases for layout, but edge cases remain. Reading exact container dimensions for animation thresholds, triggering analytics events at specific sizes, or integrating with third-party libraries that require pixel values still calls for ResizeObserver. It complements container queries rather than replacing them.

React Code Walkthrough

// CardGrid.jsx
import styles from './CardGrid.module.css';

function sanitizeUrl(url) {
  if (!url) return '#';
  const trimmed = url.trim().toLowerCase();
  if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) {
    return '#';
  }
  return url;
}

function Card({ image, alt, title, body, ctaUrl, ctaText }) {
  return (
    <article className={styles.card}>
      <div className={styles.cardInner}>
        <img src={image} alt={alt ?? ''} />
        <h3>{title}</h3>
        <p>{body}</p>
        <a href={sanitizeUrl(ctaUrl)} className={styles.cta}>{ctaText}</a>
      </div>
    </article>
  );
}

export default function CardGrid({ cards }) {
  return (
    <div className={styles.cardGrid}>
      {cards.map((card, index) => {
        const stableKey = card.id != null ? card.id : `card-fallback-${index}`;
        if (card.id == null && process.env.NODE_ENV !== 'production') {
          console.warn('CardGrid: card at index', index, 'is missing a stable `id` prop.');
        }
        return <Card key={stableKey} {...card} />;
      })}
    </div>
  );
}

Use a stable unique identifier from your data as the key prop. Index keys cause reconciliation errors when cards are reordered, filtered, or removed. The component will warn in development if a card is missing an id.

/* CardGrid.module.css */

/*
 * Note: CSS Modules do not scope `container-name` values or `@container`
 * identifiers. The name "card" below is global. Use a unique,
 * project-specific container name (e.g., "myapp-card") if your application
 * has multiple components that declare container names, to avoid collisions.
 */

.cardGrid {
  --card-rows: 4;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
  padding: 1.5rem;
}

.card {
  display: grid;
  grid-row: span var(--card-rows);
  grid-template-rows: subgrid;
  border: 1px solid var(--card-border-color, #e0e0e0);
  border-radius: 8px;
  overflow: hidden;
}

.cardInner {
  display: grid;
  grid-row: 1 / -1;
  grid-template-rows: subgrid;
  container-type: inline-size;
  container-name: card;
}

.card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.card h3 {
  padding: 0 1rem;
  margin: 0;
  font-size: clamp(1rem, 4vw, 1.5rem);
}

.card p {
  padding: 0 1rem;
  margin: 0;
}

.cta {
  padding: 0 1rem 1rem;
  align-self: end;
}

@container card (min-inline-size: 480px) {
  .cardInner {
    grid-template-columns: 200px 1fr;
  }

  .cardInner img {
    height: 100%;
    grid-row: 1 / -1;
  }

  .cardInner h3 {
    font-size: clamp(1.25rem, 3cqi, 2rem);
  }
}

@container card (max-inline-size: 479.999px) {
  .cardInner h3 {
    font-size: clamp(1rem, 5cqi, 1.5rem);
  }
}

React owns the data flow; CSS owns every responsive behavior. No resize handlers run in JavaScript, and every layout change happens through CSS alone. One practical note on tooling: Tailwind CSS v3.2+ supports container queries via the @tailwindcss/container-queries plugin; Tailwind v4 includes native support. Subgrid utilities remain limited as of Tailwind v4.x, so verify current coverage for your version.

Advanced Patterns and Gotchas

Nesting Container Queries Inside Subgrid Items

A subgridded card can itself contain a nested component, like a media object, that uses its own @container rules. This works, but requires awareness of two constraints:

  1. container-type and subgrid cannot coexist on the same element. container-type: inline-size establishes size containment, which creates a layout boundary that blocks the parent grid's track sizes from reaching the contained element. If you need both subgrid and container queries, place container-type on an inner wrapper element and keep grid-template-rows: subgrid on the outer grid participant.
  2. container-type: inline-size establishes a new block formatting context on the element it's applied to. Child margins no longer collapse through the containment boundary, and overflow defaults to visible within the new BFC. Account for both behaviors when nesting containment inside subgrid structures.

Performance Considerations

Container queries introduce a layout dependency chain: the browser sizes the container, then evaluates the query and lays out the container's children. At three or fewer nesting levels, this adds no visible frame cost (verified in Chrome DevTools Performance panel). Beyond that depth, each additional containment context adds layout recalculation passes. Profile with the DevTools Performance panel to confirm whether deeper nesting affects your specific layout. Subgrid reuses the parent's already-computed track sizing and generally does not add meaningful layout cost compared to regular grid.

Common Mistakes

Three errors appear repeatedly when developers first combine these features. Forgetting to declare container-type on the container element is the most common: without it, @container rules silently do nothing. No console warnings, no errors, just styles that never apply. Another frequent mistake is using subgrid without setting span on the parent grid item. Without an explicit span, the item occupies a single track, so subgrid has nothing useful to inherit, and the layout collapses. The third is declaring container-type: size (which contains both block and inline dimensions) when only inline-size containment is needed. The size value forces the browser to contain the element's height as well, which can produce unexpected layout results when the element's height should be determined by its content.

Forgetting to declare container-type on the container element is the most common: without it, @container rules silently do nothing. No console warnings, no errors, just styles that never apply.

Implementation Checklist

  • Define macro grid on the outermost wrapper
  • Set grid-row: span N on cards matching internal content sections (N must equal the number of direct grid children)
  • Apply grid-template-rows: subgrid on each card
  • Add container-type: inline-size to an inner wrapper element (not the subgrid participant itself)
  • Name containers with container-name when nesting (use unique names per component to avoid global collisions)
  • Write @container breakpoints based on component widths, not viewport
  • Use cqi / cqb units only inside @container blocks where containment is established; use viewport-relative units (vw) for base styles outside container queries
  • Use clamp() with cqi units to set minimum and maximum bounds for fluid internal sizing
  • Test with browser DevTools container query overlay: Chrome DevTools (Elements → Layout → Container Queries, Chrome 105+) or Firefox Inspector (Grid/Flexbox overlay panel, Firefox 110+)
  • Validate with @supports for any legacy audience, and supplement with functional testing for the combined feature implementation
  • Audit nesting depth and keep containment at three levels or fewer

Ship It Without a Polyfill

The layout trilogy of CSS Grid, Subgrid, and Container Queries is production-ready in every major browser (Chrome, Firefox, Safari, and Edge). No polyfills or JavaScript fallbacks are required. Review the Advanced Patterns section for known gotchas before shipping. The CSS Containment Level 3 and CSS Grid Level 2 specifications provide the authoritative references for edge cases and future additions.

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.