Skip to content
R.

How can CSS Grid dashboard cards wrap with repeat(auto-fit, minmax())?

Use `repeat(auto-fit, minmax())` to let dashboard cards wrap into fewer usable columns as the container narrows, instead of squashing content or forcing a horizontal scrollbar.

About 14 min readComments

A dashboard grid usually fails in one of two obvious ways: the cards stay in too many columns and become cramped, or the layout keeps those columns and spills past the viewport. The fix in this article is not about hiding overflow inside one awkward card. It is about letting the grid itself change track count as the container narrows.

`repeat(auto-fit, minmax(..., 1fr))` is the pattern that gives you that behavior. `repeat()` defines the repeating column structure, `minmax()` sets the smallest card width you are willing to accept, and `auto-fit` lets the browser drop unused tracks when the row can no longer support them. The result is a dashboard that wraps from four columns to two columns to one column without squashing the cards.

The example is intentionally small: six cards, one grid, and a clear width boundary. That keeps the layout decision easy to inspect. It also keeps this article distinct from overflow-containment fixes, because the issue here is responsive track count rather than a single oversized item that needs to be tamed.

Why dashboard cards need track wrapping instead of overflow containment

A dashboard card grid fails differently from a single oversized grid item. The cards are usually fine on their own, but the layout still has to decide how many columns can fit without making each card too narrow to scan. If the grid keeps too many columns, each metric turns into a cramped tile. If the grid refuses to drop columns, the layout spills sideways. Neither outcome helps a reader compare values quickly.

That is why this article uses a layout-level fix rather than the kind of containment fix that helps an individual item shrink. The example cards are intentionally small: a short heading, a short description, and enough body text to make the card real without turning the demo into a content problem. The point is to isolate the track decision itself. We want to see the grid choose fewer columns as the container narrows, not hide a broken item inside a scroll boundary.

The supplied implementation uses `repeat(auto-fit, minmax(180px, 1fr))`. That combination says something precise: make as many columns as fit, but never let a card track shrink below 180 pixels. Once the container can no longer support another 180-pixel track plus the gap, the browser should stop creating that track and let the existing cards reflow into fewer columns. This is the behavior dashboards usually want, because the reading order stays intact while the layout becomes more vertical instead of more cramped.

main.mjs / javascript
export const MIN_CARD_WIDTH = 180;

const defaultCards = [
  'Revenue',
  'Active users',
  'Open tickets',
  'Conversion',
  'Deployments',
  'Uptime',
];

function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

export function dashboardHtml(cards = defaultCards) {
  const cardMarkup = cards
    .map(
      (title, index) => `
        <article class="card" data-card="${index}">
          <h2>${escapeHtml(title)}</h2>
          <p>Dashboard metric ${index + 1}</p>
        </article>`,
    )
    .join('');

  return `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
      * { box-sizing: border-box; }
      html, body { margin: 0; }
      body {
        font-family: system-ui, sans-serif;
        background: #e2e8f0;
      }
      .dashboard {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(${MIN_CARD_WIDTH}px, 1fr));
        gap: 16px;
        width: 100%;
      }
      .card {
        min-height: 72px;
        padding: 12px;
        border: 1px solid #94a3b8;
        border-radius: 8px;
        background: white;
      }
      h2 { margin: 0; font: 600 16px system-ui, sans-serif; }
      p { margin: 8px 0 0; font: 14px system-ui, sans-serif; }
    </style>
  </head>
  <body>
    <main class="dashboard" aria-label="Dashboard cards">${cardMarkup}</main>
  </body>
</html>`;
}

SourcesMDN: Basic concepts of grid layout (opens a new tab)MDN: minmax() (opens a new tab)

How `repeat(auto-fit, ...)` changes the number of tracks

`repeat()` is not just a convenience wrapper. In Grid, it still creates a real track listing. With a fixed count such as `repeat(4, 1fr)`, the browser commits to four explicit columns, even if that means each one becomes awkwardly small on a narrow container. That works when the design truly needs a fixed matrix, but a dashboard usually does not want to hold a constant column count at any cost.

`auto-fit` changes the contract. Instead of saying “always four columns,” it says “create as many columns as fit, then let the count shrink when the available space can no longer support another track.” That is why the same card markup can appear as four columns on a wide container, two columns in the middle range, and one column when the viewport gets tight. The item count does not change; only the track count changes.

This distinction matters because a responsive dashboard is usually judged by scanning efficiency, not by preserving a perfect row count. If the browser can fit four sensible cards per row, that is great. If it can only fit two sensible cards per row, forcing four columns would make the page harder to read. `auto-fit` lets the browser prefer legibility over rigid symmetry. The layout remains a grid, but the grid adapts to the space instead of demanding the space adapt to the grid.

SourcesMDN: Basic concepts of grid layout (opens a new tab)

Why the minimum belongs in `minmax(180px, 1fr)`

`minmax()` is the other half of the pattern. It defines a range, not just a size. The minimum protects the card from becoming too narrow, and the maximum lets the track grow once the browser has decided how many columns fit. In this implementation, `180px` is the smallest width the design is willing to accept for a dashboard card, and `1fr` lets surviving columns share the remaining space evenly.

That choice is more deliberate than a fixed-width column. A fixed track can leave awkward unused space or keep the grid rigid when the container gets wider. A flexible `1fr` track, by contrast, expands naturally after the browser has resolved how many tracks can fit. The cards remain aligned and balanced, but they are not forced to stay tiny just because the viewport is smaller than desktop size.

The minimum also answers a practical question that dashboards often face: at what point does a card stop reading like a card? That threshold depends on the content. A short label and a small value can tolerate a narrower card than a dense summary with supporting text. The 180-pixel floor in this fixture is not a universal law; it is the chosen breakpoint inside the track-sizing formula. The real lesson is that the minimum belongs in the track definition, not in a separate media query that guesses when the layout should change.

SourcesMDN: minmax() (opens a new tab)

What the fixture proves at several viewport widths

The implementation is easiest to understand when you read it as a width-to-column relationship. At a wide viewport, the dashboard resolves into four columns and two rows for the six-card fixture. That means the browser found room for four minimum-width tracks and distributed the remaining space across them. Nothing about the markup changed; the column count came from the available inline size.

At a medium viewport, the same cards wrap into two columns and three rows. This is the important step for the article’s question, because it shows the browser is reducing the number of tracks before the cards become too narrow. The grid is not squeezing the cards smaller than the configured minimum. It is choosing fewer columns instead.

At 376 pixels, the example still fits two tracks because the two 180-pixel tracks plus the 16-pixel gap can exactly fit. At 375 pixels, the second track no longer fits, so the grid drops to one column. That one-pixel boundary is useful because it demonstrates that the layout follows the actual track math instead of an arbitrary “small screen” breakpoint. The grid changes behavior right where the formula says it should.

The supplied tests also verify that every card remains inside the container boundary. That matters because a wrapping layout only solves the problem if it removes horizontal overflow rather than hiding it. The visible promise is not just “the cards stack differently.” The promise is “the dashboard stays readable and contained as width shrinks.”

SourcesMDN: Basic concepts of grid layout (opens a new tab)MDN: minmax() (opens a new tab)

Why a partial fix is not the same as the full answer

It is easy to confuse this pattern with other grid fixes because they all involve responsive behavior, but they solve different problems. If a card’s own content overflows, `repeat(auto-fit, minmax())` does not magically make that content shorter. If the label is too long, the value wraps badly, or the card contains a control that refuses to shrink, then the card still needs internal design work. The outer grid can only decide how many cards fit per row; it cannot make every card’s contents behave.

The opposite mistake is to treat a rigid column count as if it were a responsive solution. A four-column dashboard may look fine on a large desktop screen and still become awkward on smaller widths. If the layout never changes track count, the cards become tiny before the browser ever gives up a column. That is the failure this article avoids. The pattern is not “always use auto-fit.” It is “use auto-fit when the number of columns should adapt before the card content becomes illegible.”

A second partial fix is to reach for a media query and hard-code separate layouts for each breakpoint. That can work, but it adds maintenance cost and moves the column decision away from the track sizing rule itself. The current implementation keeps the decision where Grid already understands it: in the column definition. That makes the behavior easier to reason about and easier to keep consistent as the dashboard changes size or gains more cards.

SourcesMDN: Basic concepts of grid layout (opens a new tab)

Limits, tradeoffs, and when another layout is better

This pattern is a good fit when each card is a standalone unit of information and can move between one, two, or several columns without changing meaning. It is less suitable when the design requires a strict matrix, a guaranteed row alignment across content types, or identical cross-row relationships. A dashboard can usually adapt its track count. A table or comparison grid often should not.

The chosen minimum width also matters more than people expect. Too small a minimum makes the cards feel compressed even though the layout is technically responsive. Too large a minimum causes the grid to drop to fewer columns too early. The minimum should reflect the point where the card still reads as a card rather than a squeezed fragment. In other words, the track size is part of the content design, not just the CSS syntax.

There are also browser-environment limits worth keeping in mind. The layout responds to available inline space, so zoom level, font metrics, and scrollbar behavior can shift the exact threshold at which a column disappears. That is normal. The important behavior is relative: as the container narrows, the layout should reduce track count instead of forcing a horizontal scrollbar or making each track absurdly small. When that is the requirement, `repeat(auto-fit, minmax())` is a compact and accurate fit for dashboard cards.

main.test.mjs / javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
import { dashboardHtml, MIN_CARD_WIDTH } from './main.mjs';

async function openPage(width, cards = undefined) {
  const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
  try {
    const context = await browser.newContext({ viewport: { width, height: 600 } });
    await context.route('**/*', (route) => route.abort());
    const page = await context.newPage();
    await page.setContent(dashboardHtml(cards));
    await page.waitForFunction(() => document.querySelectorAll('.card').length > 0);
    return { browser, context, page };
  } catch (error) {
    await browser.close();
    throw error;
  }
}

async function measure(page) {
  return page.evaluate(() => {
    const grid = document.querySelector('.dashboard').getBoundingClientRect();
    const cards = [...document.querySelectorAll('.card')].map((card) => {
      const rect = card.getBoundingClientRect();
      return {
        left: Math.round(rect.left),
        top: Math.round(rect.top),
        right: Math.round(rect.right),
        width: Math.round(rect.width),
        text: card.querySelector('h2')?.textContent,
      };
    });
    return {
      grid: { left: Math.round(grid.left), right: Math.round(grid.right) },
      columns: new Set(cards.map((c) => c.left)).size,
      rows: new Set(cards.map((c) => c.top)).size,
      cards,
    };
  });
}

test('wide dashboards use four columns and keep every card at or above the minimum width', async () => {
  const { browser, page } = await openPage(900);
  try {
    await page.waitForFunction(() => new Set([...document.querySelectorAll('.card')].map((c) => Math.round(c.getBoundingClientRect().left))).size === 4);
    const result = await measure(page);

    assert.equal(result.columns, 4);
    assert.equal(result.rows, 2);
    assert.equal(result.cards.length, 6);
    assert.ok(result.cards.every((card) => card.width >= MIN_CARD_WIDTH));
    assert.ok(result.cards.every((card) => card.left >= result.grid.left && card.right <= result.grid.right));
  } finally {
    await browser.close();
  }
});

test('at 376px the layout still fits two minimum-width tracks plus the gap', async () => {
  const { browser, page } = await openPage(376);
  try {
    await page.waitForFunction(() => new Set([...document.querySelectorAll('.card')].map((c) => Math.round(c.getBoundingClientRect().left))).size === 2);
    const result = await measure(page);

    assert.equal(result.columns, 2);
    assert.equal(result.rows, 3);
    assert.ok(result.cards.every((card) => card.width >= MIN_CARD_WIDTH));
    assert.ok(result.cards.every((card) => card.right <= result.grid.right));
  } finally {
    await browser.close();
  }
});

test('at 375px the second 180px track no longer fits, so the cards collapse to one column', async () => {
  const { browser, page } = await openPage(375);
  try {
    await page.waitForFunction(() => new Set([...document.querySelectorAll('.card')].map((c) => Math.round(c.getBoundingClientRect().left))).size === 1);
    const result = await measure(page);

    assert.equal(result.columns, 1);
    assert.equal(result.rows, 6);
    assert.ok(result.cards.every((card) => card.right <= result.grid.right));
  } finally {
    await browser.close();
  }
});

test('dashboardHtml escapes card titles instead of injecting raw HTML', async () => {
  const { browser, page } = await openPage(500, ['<Revenue & Growth>']);
  try {
    await page.waitForFunction(() => document.querySelector('.card h2')?.textContent === '<Revenue & Growth>');
    const html = await page.textContent('.card h2');
    const markup = await page.content();

    assert.equal(html, '<Revenue & Growth>');
    assert.match(markup, /&lt;Revenue &amp; Growth&gt;/);
  } finally {
    await browser.close();
  }
});

SourcesMDN: minmax() (opens a new tab)MDN: Basic concepts of grid layout (opens a new tab)

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed