Building a Responsive Dashboard Where Cards Align with Subgrid

Problem statement

You are building an analytics dashboard: a responsive grid of metric cards, each with a title, a chart or value area, and a footer of secondary stats. The grid auto-fits as many columns as fit, so cards land in rows of two, three, or four depending on viewport width. The problem is alignment. One card's title wraps to two lines, another's footer has three stats instead of one, and the result is a ragged row where nothing lines up. You want every card's title row, body row, and footer row to share a baseline across the entire row, with no fixed heights and no JavaScript measuring elements. This page solves exactly that with subgrid, then layers @container on top so each card's interior adapts to its own width. It builds directly on the techniques in the CSS Grid and Subgrid Layouts guide under Mastering Container Queries & Responsive Layouts.

Approach rationale

The pre-subgrid solutions were all compromises. Fixed min-height on each section guesses at content length and breaks the moment a label is longer than expected. A shared flex row forces every card into one row and abandons the auto-fit wrapping you want. JavaScript that measures the tallest card and sets the rest to match works, but it runs on every resize, fights the browser's own layout pass, and adds a layout-thrash hazard plus a hydration cost.

subgrid removes the guesswork entirely. The dashboard grid owns the row tracks; each card spans those rows and inherits their sizes, so alignment is computed once by the engine during normal layout. There is no measurement step and no reflow loop. The accessibility tradeoff is favorable too: because alignment is achieved without reordering or absolute positioning, DOM order stays equal to reading order, so keyboard and screen-reader users traverse the dashboard exactly as it appears. The only cost is a @supports gate for older browser versions, which is cheap.

Complete working implementation

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Subgrid dashboard</title>
<style>
  :root { color-scheme: light dark; }
  body {
    margin: 0;
    font: 16px/1.5 system-ui, sans-serif;
    background: #f4f6fb;
    color: #1c2430;
  }

  .dashboard {
    display: grid;
    /* Auto-fitting columns: no breakpoints needed for the grid itself. */
    grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
    /* The shared row tracks every card will align onto:
       title (auto), body (flexible), footer (auto). */
    grid-auto-rows: auto 1fr auto;
    gap: 1.25rem;
    padding: 1.5rem;
    max-width: 76rem;
    margin-inline: auto;
  }

  .card {
    display: grid;
    /* Span the three implicit rows the parent created, and adopt them. */
    grid-row: span 3;
    grid-template-rows: subgrid;
    row-gap: 0.75rem;

    background: Canvas;
    border: 1px solid #d4dbe8;
    border-radius: 0.9rem;
    padding: 1.1rem 1.25rem;
    box-shadow: 0 1px 2px rgb(20 36 48 / 0.06);

    /* Query the card's own width, not the viewport. */
    container-type: inline-size;
    container-name: card;
  }

  .card__title {
    margin: 0;
    font-size: 0.95rem;
    font-weight: 600;
    color: #5a6472;
    text-transform: uppercase;
    letter-spacing: 0.03em;
  }

  .card__value {
    margin: 0;
    font-size: 2.4rem;
    font-weight: 700;
    line-height: 1.1;
  }

  .card__delta { font-size: 0.9rem; color: #1b8a5a; }
  .card__delta--down { color: #c23b3b; }

  .card__footer {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem 1rem;
    margin: 0;
    padding-top: 0.5rem;
    border-top: 1px solid #e6eaf2;
    font-size: 0.85rem;
    color: #5a6472;
    list-style: none;
  }

  /* Card-level responsiveness: when a single card is wide,
     place the value beside its delta instead of stacking. */
  @container card (min-width: 18rem) {
    .card__body {
      display: grid;
      grid-template-columns: 1fr auto;
      align-items: baseline;
      column-gap: 0.75rem;
    }
  }

  /* Older browsers: keep cards usable without subgrid alignment. */
  @supports not (grid-template-rows: subgrid) {
    .card { grid-row: auto; grid-template-rows: auto 1fr auto; }
  }
</style>
</head>
<body>
  <main class="dashboard">
    <section class="card">
      <h2 class="card__title">Monthly Recurring Revenue</h2>
      <div class="card__body">
        <p class="card__value">$48.2k</p>
        <span class="card__delta">+12.4%</span>
      </div>
      <ul class="card__footer">
        <li>New $6.1k</li>
        <li>Churn $1.3k</li>
      </ul>
    </section>

    <section class="card">
      <h2 class="card__title">Active Users</h2>
      <div class="card__body">
        <p class="card__value">12,940</p>
        <span class="card__delta card__delta--down">-2.1%</span>
      </div>
      <ul class="card__footer">
        <li>DAU 4.2k</li>
      </ul>
    </section>

    <section class="card">
      <h2 class="card__title">Average Response Time Across All Regions</h2>
      <div class="card__body">
        <p class="card__value">214ms</p>
        <span class="card__delta">+8ms</span>
      </div>
      <ul class="card__footer">
        <li>p50 180ms</li>
        <li>p95 410ms</li>
        <li>p99 920ms</li>
      </ul>
    </section>
  </main>
</body>
</html>

Notice that the three cards carry wildly different content: a two-line title on the third card, a single footer stat on the second, three on the third. Yet the value rows align, the footer top-borders align, and the cards are the same height per row. That alignment is produced entirely by the parent's grid-auto-rows plus each card's grid-template-rows: subgrid.

Dashboard cards aligned by subgrid row lines Three cards with different amounts of content share three horizontal baselines for the title, value, and footer rows because each card subgrids onto the parent's rows. Shared baselines via subgrid rows title line value line footer line MRR Users Latency $48.2k 12,940 214ms 2 stats 1 stat 3 stats different content, same row lines

The demo is a cut-down version of the dashboard. Each card's title, value and footer sit in shared row bands, so even with different amounts of text the values and footers line up across the row. Drag the frame to change the column count; alignment holds within every row.

Live demoDashboard cards aligned with subgrid
Each card spans three rows and uses grid-template-rows: subgrid, so titles, values and footers align across a row even with different text lengths. Resize to change the column count. Drag the bottom-right corner to resize the frame in either direction.

Key technique callout

The whole effect hinges on two declarations working as a pair. On the parent, grid-auto-rows: auto 1fr auto defines the three row tracks that the implicit rows will cycle through. On each card, grid-row: span 3 claims three of those rows and grid-template-rows: subgrid makes the card adopt their exact sizes instead of computing its own. Because all cards in a row span the same three parent rows, and those rows size to fit the tallest content in each band, every card's bands align. Drop the span 3 and a card inherits only one line pair, collapsing back to a single shared track; drop subgrid and each card sizes its rows independently and alignment vanishes. Both halves are required.

grid-auto-rows as a repeating pattern A vertical list of implicit row tracks labelled auto, 1fr, auto, auto, 1fr, auto. Brackets show the first row of cards spanning tracks 1 to 3 and the second row of cards spanning tracks 4 to 6. Because each card spans exactly three tracks, every card aligns with the title, body and footer pattern. grid-auto-rows: auto 1fr auto 1 · auto (title) 2 · 1fr (body) 3 · auto (footer) 4 · auto 5 · 1fr 6 · auto card row 1: span 3 card row 2: span 3

When a card has less content

Real dashboards have gaps: a metric without a trend line, a card with no footer action. Subgrid handles these gracefully as long as the card keeps its three-band structure. An empty footer band still exists as a track; it simply sizes to the tallest footer among its row-mates, so the missing footer leaves blank space in line with the others rather than pulling the card's body out of alignment. If a card omits its footer element entirely, its body now spans only the first two bands, which is also fine — but do not let optional content change the number of rows a card spans. A card spanning two rows while its neighbours span three shifts every subsequent card's position in the implicit grid, and the row pattern goes out of phase.

The safest rule is to render every band in every card, even if some are empty, and to hide an empty band's decoration (a border-top on the footer, for example) with :empty or :has() rather than removing the element. The layout then stays predictable regardless of the data, and Empty and Loading States With () shows the selectors for detecting empty bands. A quick way to test the rule is to blank out one card's footer in DevTools and confirm that nothing else on the dashboard moves.

Variation or extension: reduced-motion aware card entrance

Dashboards often animate cards in on load. Keep that motion optional. The animation below fades and lifts each card, but collapses to an instant fade for anyone who has requested reduced motion, the same discipline taught in the Keyframe Animation Patterns guide where animating layout-affecting changes is covered.

@media (prefers-reduced-motion: no-preference) {
  .card {
    animation: card-in 360ms ease-out both;
  }
}

@keyframes card-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}

/* Reduced-motion users still get the content, just without movement. */
@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }
}

Browser support note

Subgrid is supported in Firefox 71+, Safari 16+, and Chrome/Edge 117+, which covers every current evergreen browser as of 2026, and the @container query layer is baseline since 2023. The only feature needing a fallback is subgrid in older versions, which the @supports not (grid-template-rows: subgrid) block above handles by restoring per-card row tracks; cards stay fully usable, they just lose cross-row baseline alignment.

FAQ

Why do my dashboard cards refuse to align across a row without subgrid? Each card runs its own independent grid, so its internal rows are sized only by its own content. Subgrid makes every card share the parent grid's row lines, so the title, body, and footer rows line up across all cards.

How many parent rows should a card span when using subgrid? Span exactly as many rows as the shared internal sections you want aligned. For a title, body, and footer card that is three rows, written as grid-row: span 3 with grid-template-rows: subgrid.

Can I combine subgrid alignment with container queries on the same card? Yes. Subgrid governs how the card aligns with its siblings on the dashboard grid, while a container query restyles the card's interior based on the card's own width. They operate on different layers and do not conflict.

What happens to subgrid in browsers that do not support it? Without a gate the card falls back to its own row tracks and loses cross-row alignment but stays usable. Wrap the subgrid rules in @supports (grid-template-rows: subgrid) and provide fixed inner rows as the baseline.

Related articles

More pages in the same section.