Build a card grid that reflows without media queries

The problem

A listing page shows an unknown number of cards in one region. The design asks for a card that is never narrower than a comfortable preview, columns that line up in every row, equal-height cards whose links sit on the same edge, and a layout that still works when the same markup is dropped into a sidebar or a search result with two hits. Current browsers assumed; the card markup is a list and is not to be restructured per breakpoint.

Short answer

Declare the column list once as repeat(auto-fit, minmax(min(18rem, 100%), 1fr)), keep the floor in a custom property, and let the browser fit as many tracks of that minimum as the grid container can hold; the count is computed at layout time, so no breakpoint is involved.

A grid that decides its own column count is one track list: name the smallest card you will accept, and ask the browser how many of them fit. The count stops being a number you maintain per viewport and becomes something layout computes, which is why the same rules serve a full-width listing, a search result with two hits, and a sidebar.

Language: HTML
<ul class="card-grid">
  <li class="card">
    <h2 class="card-title">Debounce a scroll handler</h2>
    <p class="card-text">Collapse a burst of events into the one call that still reports the final position.</p>
    <a class="card-link" href="/javascript/debounce-a-scroll-handler/">Read the example</a>
  </li>
  <li class="card">
    <h2 class="card-title">Keep a table header visible</h2>
    <p class="card-text">Sticky offsets need a scroll box with a definite block size.</p>
    <a class="card-link" href="/css/apply-sticky-positioning-to-a-table-header/">Read the example</a>
  </li>
  <li class="card">
    <h2 class="card-title">Validate form input before submit</h2>
    <p class="card-text">Cross-field rules can join the browser's own validity state.</p>
    <a class="card-link" href="/javascript/validate-form-input-before-submit/">Read the example</a>
  </li>
</ul>

Language: CSS
.card-grid {
  --card-min: 18rem;

  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min), 100%), 1fr));
  gap: 1.25rem 1.5rem;
  align-items: stretch;   /* the default, stated because the equal heights depend on it */
  margin: 0;
  padding: 1.5rem;
  list-style: none;
  max-inline-size: 80rem;
}

.card {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1rem 1.125rem;
  border: 1px solid var(--de-border, #c7ccd4);
  border-radius: 0.75rem;
  background-color: var(--de-surface, #f6f7f9);
}

.card-title {
  margin: 0;
  font-size: 1.0625rem;
}

.card-text {
  margin: 0;
  flex: 1;                  /* absorbs the height difference so links align across a row */
  overflow-wrap: break-word;
}

.card-link {
  align-self: flex-start;
}

Language: CSS
/* auto-fill keeps the tracks that hold no card, so a short line stays short. */
.card-grid--fill {
  grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
}

/* Same rules, narrower space: the floor is what changes, not the technique. */
.sidebar .card-grid {
  --card-min: 12rem;

  padding: 0;
  gap: 0.75rem;
}

Explanation

repeat(auto-fit, …) asks a single question at layout time: how many tracks of at least this size does the grid container’s inline size hold? The answer replaces the breakpoint set. With the values above, a 60rem container leaves a 57rem content box after padding, three 18rem tracks plus two 1.5rem gaps take exactly 57rem, and a fourth track would need 76.5rem, so the row is three columns and every one of them is 18rem wide. Widen the container to the 80rem cap and a fourth track becomes possible, landing at about 18.1rem each; narrow it and tracks fall away one at a time. Nothing in the stylesheet names a viewport width, and the same arithmetic is what makes the grid behave in a sidebar: it is the container that is measured, so a 20rem column gives one track without a single extra rule.

minmax() is where the two halves of the requirement split. Its first argument is a floor, and using min(var(--card-min), 100%) rather than a bare 18rem is what protects the narrow end: a track can never be smaller than its minimum, so an 18rem floor inside a 15rem container forces the grid wider than the page and buys a horizontal scrollbar, while the min() guard quietly substitutes the container’s own width as the floor and produces one full-width card. The second argument is the ceiling, and 1fr gives every track in the row an equal share of whatever space is left after the gaps, which is what keeps column edges lined up. Using auto there instead lets each track be content-sized, so two short cards sit narrow beside a long one and the row stops reading as a grid.

The card’s own rules are what make equal heights legible rather than accidental. Grid items stretch to their row by default, so the tallest card in a line sets that line’s height and every sibling is grown to match; align-items: stretch is stated here only so the dependency is visible. Inside the card, flex-direction: column with flex: 1 on the text gives the paragraph all the height the card has left, which parks each link on the same bottom edge no matter how many lines the summaries take. list-style: none and the reset padding keep the semantic list intact for a reader using a screen reader, where the count of items is genuinely useful information that a set of div elements would not carry.

Three boundaries are worth checking before this goes into a template. auto-fit collapses tracks that hold no card and hands their space to the tracks that do, so two cards in a container that could hold four each become half the page wide; when the design wants cards to keep their natural column width and simply leave the line unfinished, that is the auto-fill variant shown above. Because no query is involved, gap and padding cannot change with the space either, so a layout that wants tight gaps on phones still needs a media query for those two values while the track list stays query-free. And the grid never rescues the content inside a card: the measure grows with the track, a long unbreakable string overwrites its own edge without overflow-wrap, and a low floor in a very wide container is how a card grid ends up with 120-character lines.

Next steps

One track list answers how many cards fit. The next question is what happens when the same page also needs one column that never moves, which is where a fixed sidebar track and a fluid content track are decided, and where a grid placed inside a scrolling container starts sharing behaviour with sticky headers.

Parameters

Named inputs for the code above
NameTypeRequiredDefaultDescription
card minimumlength in a custom propertyYes18rem in the code aboveThe narrowest card the design accepts, and the only value that decides how many tracks fit.
track maximumflex or length valueYes1frGives every track in a row an equal share of the leftover space, which is what keeps the columns lined up.
repeat keywordauto-fit or auto-fillYes—Decides whether tracks that hold no card collapse so the row fills out, or stay in place and leave the line unfinished.

Expected output

A 60rem container lays out exactly three 18rem columns; at the 80rem cap the same rules give four of about 18.1rem; narrow the container and tracks drop out one at a time until one card spans the width, with no horizontal scrollbar at any size.

Usage notes

  • Keep the floor in one custom property so the column count moves when the design changes instead of when someone remembers to edit a second rule.
  • Measure the grid against the container you actually render into; a grid inside a 20rem column behaves exactly as the same rules would on a phone.

Common mistakes

  • Writing minmax(18rem, auto), which hands the maximum to the content, so columns stop lining up on wide screens.
  • Subtracting gaps from percentage widths by hand, which produces a layout that cannot be counted and breaks as soon as the gap changes.
  • Reaching for a media query per column count, then maintaining three numbers for every design change instead of one.

Caveats

  • auto-fit stretches a short line to fill the row when there are fewer cards than tracks, so a teaser strip that must keep card width is the auto-fill case instead.
  • Removing the breakpoint for column count does not remove the need for one for gap and padding, which still want different values at different sizes.
  • A track minimum is a floor, not a clip: a long unbreakable string in a card still overflows its track unless the text is allowed to break.
  • The card carries the reading measure, so a low floor in a very wide container buys long lines; cap the container width as well as the track.

Follow-up

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References