What CSS Grid is

CSS Grid (officially the CSS Grid Layout Module) is a layout system that arranges the children of an element in rows and columns at the same time. You switch it on with display: grid. The element becomes a grid container, and each of its direct children becomes a grid item. Grandchildren are not affected: they lay out normally inside their grid item.

Flexbox (see Flexbox) is one-dimensional: it puts items in a row or a column, and each line of a wrapped flex container sizes itself on its own. A grid controls both directions at once, so items in different rows still line up in the same columns. That makes it the natural tool for page layouts, dashboards, card collections and forms with aligned labels. Grid has worked in all major browsers since 2017.

The Kestrel Energy outage page shows six figures as tiles. Three equal columns and a gap are all it takes:

<div class="tiles">
  <div>Open outages: 3</div>
  <div>Customers affected: 1,240</div>
  <div>Crews on site: 5</div>
  <div>Average restore time: 2 h 10 min</div>
  <div>Calls waiting: 18</div>
  <div>Last update: 14:05</div>
</div>
.tiles {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 0.75rem;
}

.tiles > div {
  border: 1px solid #555;
  padding: 0.75rem;
}

There are six items and three columns, so the grid creates two rows by itself. The tiles in each row get the height of the tallest one, and the columns stay the same width even when one tile has more text.

Rows, columns and tracks

A grid track is one row or one column of the grid: the space between two neighboring grid lines. You define column tracks with grid-template-columns and row tracks with grid-template-rows. Each value in the list is one track, so grid-template-columns: 8rem 1fr creates two columns: a fixed one of 8rem and a flexible one that takes the rest. A single grid cell is where one row and one column cross.

The space between tracks is set with gap (or row-gap and column-gap separately), the same property flexbox uses. Gaps only appear between tracks, never at the outer edges, so you do not need margin tricks. Older code writes grid-gap, the original name, which browsers still accept as an alias.

The explicit and the implicit grid

The tracks you define yourself form the explicit grid. When there are more items than cells, the browser adds tracks automatically: the implicit grid. By default extra items go into new rows, and those rows are sized by grid-auto-rows (default auto: as tall as their content). This is why you often define only the columns and let the rows appear as needed.

A description list of meter details is a good fit: every <dt> goes into the first column, every <dd> into the second, and the rows are implicit.

<dl class="meter">
  <dt>Meter ID</dt>
  <dd>E-0042-7781</dd>
  <dt>Address</dt>
  <dd>Laan van Meerdervoort 12, Den Haag</dd>
  <dt>Last reading</dt>
  <dd>18,412 kWh on 1 September 2026</dd>
  <dt>Next reading due</dt>
  <dd>1 December 2026</dd>
</dl>
.meter {
  display: grid;
  grid-template-columns: 9rem 1fr;
  gap: 0.25rem 1rem; /* row gap, column gap */
}

.meter dt {
  font-weight: bold;
}

.meter dd {
  margin: 0; /* remove the default indent */
}

Because the labels share one column, they line up without fixed widths on each label, and a long address simply wraps inside its cell.

The fr unit

The fr unit (fraction) represents a share of the free space in the grid container. The browser first gives fixed tracks (px, rem, percentages) their size and subtracts the gaps; whatever is left is divided between the fr tracks in proportion to their numbers.

<div class="row">
  <div>150px</div>
  <div>1fr</div>
  <div>2fr</div>
</div>
.row {
  display: grid;
  grid-template-columns: 150px 1fr 2fr;
  gap: 0.5rem;
}

.row > div {
  background: #e3ecf7;
  border: 1px solid #2b5a8a;
  padding: 1rem 0.5rem;
  text-align: center;
}

In a container 560px wide, the gaps take 16px and the first column 150px. The remaining 394px is split into three shares: the 1fr column gets one (about 131px), the 2fr column two (about 263px). Make the window narrower and only the fr columns shrink.

Why not just use percentages? 33.33% columns plus a gap add up to more than 100%, so the grid overflows; you would have to subtract the gaps yourself with calc(). fr works with what is left after the gaps, so it never overflows because of them.

Placing items by line number

A grid line is one of the dividing lines on either side of a track. Lines are numbered from 1, starting at the start edge (the left in English, the top for rows). A grid with three columns has four column lines: 1, 2, 3 and 4. Lines can also be counted from the end with negative numbers: -1 is always the last line of the explicit grid.

A grid of three columns and two rows. Column lines are numbered 1 to 4 along the top and -4 to -1 along the bottom; row lines are numbered 1 to 3 on the left. An item placed with grid-column 1 / 3 and grid-row 1 covers the first two cells of the top row. grid-column: 1 / 3 one cell 1 2 3 4 -4 -3 -2 -1 1 2 3 column lines (top: counted from the start, bottom: from the end) rows
Three columns need four column lines. The highlighted item starts at column line 1 and ends at line 3, so it covers two column tracks.

You place an item with grid-column and grid-row, each written as start / end. Instead of an end line you can write span n: the number of tracks to cover. The Kestrel portal dashboard puts the outage map over three columns and two rows, and a timeline over the full width:

<div class="dashboard">
  <section class="map">Outage map</section>
  <section>Alerts</section>
  <section>Crews</section>
  <section class="timeline">Timeline</section>
</div>
.dashboard {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  gap: 0.5rem;
}

.dashboard > section {
  border: 1px solid #555;
  padding: 0.75rem;
}

.map {
  grid-column: 1 / 4;      /* from line 1 to line 4: three columns */
  grid-row: 1 / span 2;    /* start at row line 1, cover two rows */
  min-height: 8rem;
}

.timeline {
  grid-column: 1 / -1;     /* first line to last line: full width */
}

Alerts and Crews have no placement, so the browser's auto-placement puts them in the first free cells: column 4 of row 1 and column 4 of row 2. The timeline is placed by line and lands in row 3.

Named grid areas

A grid area is a rectangle of one or more cells that an item occupies. You can give areas names and draw the layout as text with grid-template-areas: each quoted string is one row, each word one cell. Repeating a name over several cells makes that area bigger; a period (.) leaves a cell empty. Each item then says which area it goes into with grid-area.

<div class="page">
  <header>Kestrel Energy</header>
  <nav aria-label="Main"><a href="#">Outages</a></nav>
  <main>Outage map and list</main>
  <aside>Planned maintenance</aside>
  <footer>Contact and opening hours</footer>
</div>
.page {
  display: grid;
  grid-template-columns: 8rem 1fr 9rem;
  grid-template-areas:
    "header header header"
    "nav    main   aside"
    "footer footer footer";
  gap: 0.5rem;
}

.page > * {
  border: 1px solid #555;
  padding: 0.5rem;
}

header { grid-area: header; }
nav    { grid-area: nav; }
main   { grid-area: main; min-height: 6rem; }
aside  { grid-area: aside; }
footer { grid-area: footer; }

The layout is readable straight from the CSS, and changing it is a matter of redrawing the strings. That makes named areas a good match for responsive design: a media query can redefine grid-template-areas while the items keep their names. For a phone, a single column could look like this (see Responsive Design for media queries):

.page {
  grid-template-columns: 1fr;
  grid-template-areas:
    "header"
    "nav"
    "main"
    "aside"
    "footer";
}

The rules for the strings: every row needs the same number of cells, and each name must form one rectangle. An L-shaped area makes the whole grid-template-areas declaration invalid, and the browser ignores it.

repeat() and minmax()

Writing 1fr 1fr 1fr 1fr gets tedious. repeat(4, 1fr) means the same: the second argument is repeated the given number of times. It can repeat a pattern too: repeat(2, 10rem 1fr) produces four tracks.

minmax(min, max) defines a track size range: the track is never smaller than min and never larger than max. minmax(10rem, 1fr) is a flexible column that will not shrink below 10rem; minmax(auto, 20rem) grows with its content up to 20rem.

Why a 1fr column sometimes refuses to shrink

A plain 1fr track is really minmax(auto, 1fr): its minimum is the smallest size of its content. Long content that cannot wrap, such as a log line in a <pre>, a long URL or a wide table, then pushes the column wider than its share and the whole grid overflows. The fix is to allow the track to shrink below its content with minmax(0, 1fr) and let the wide content scroll in its own box:

<p><strong>1fr 1fr</strong></p>
<div class="ticket plain">
  <div>INC-31902: portal logins fail</div>
  <pre>2026-10-01T19:35:02Z auth-gateway ERROR upstream timeout after 30000 ms</pre>
</div>

<p><strong>minmax(0, 1fr) minmax(0, 1fr)</strong></p>
<div class="ticket fixed">
  <div>INC-31902: portal logins fail</div>
  <pre>2026-10-01T19:35:02Z auth-gateway ERROR upstream timeout after 30000 ms</pre>
</div>
.ticket {
  display: grid;
  gap: 0.5rem;
}

.ticket > * {
  border: 1px solid #555;
  padding: 0.5rem;
  margin: 0;
}

.plain {
  grid-template-columns: 1fr 1fr;
}

.fixed {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}

.fixed pre {
  overflow-x: auto; /* the log line scrolls inside its own box */
}

In the first grid the log line forces its column wide and the grid sticks out of the frame (you can scroll the result sideways). In the second, both columns keep their equal share and only the log box scrolls.

auto-fit and auto-fill

Instead of a number, repeat() accepts the keywords auto-fill and auto-fit: "as many tracks as fit". Combined with minmax() this gives a responsive card grid in one line, without any media query:

<ul class="tariffs">
  <li><strong>Green Fixed</strong><br>Wind and solar, price fixed for 3 years.</li>
  <li><strong>Green Flex</strong><br>Wind and solar, monthly price.</li>
  <li><strong>Dynamic</strong><br>Hourly market price.</li>
  <li><strong>Business</strong><br>For small companies.</li>
  <li><strong>Heat</strong><br>District heating.</li>
</ul>
.tariffs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr));
  gap: 0.75rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.tariffs li {
  border: 1px solid #555;
  padding: 0.75rem;
}

Read the column definition as: "make as many columns as fit, each at least 10rem wide, and share any leftover space equally". In the content column of this page that is three columns; resize the window and the grid drops to two and then one column on its own. The inner min(10rem, 100%) prevents overflow when the container itself is narrower than 10rem.

auto-fit versus auto-fill

Both keywords create as many tracks as fit. The difference shows when there are fewer items than tracks. auto-fit collapses the empty tracks to zero, so the items stretch to fill the row. auto-fill keeps the empty tracks, so the items keep their share and empty space remains at the end:

<p>auto-fill</p>
<div class="cards fill"><div>Electricity</div><div>Gas</div></div>
<p>auto-fit</p>
<div class="cards fit"><div>Electricity</div><div>Gas</div></div>
.cards {
  display: grid;
  gap: 0.5rem;
}

.cards div {
  border: 1px solid #555;
  padding: 0.5rem;
}

.fill { grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); }
.fit  { grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); }

With many items the two behave the same. For a card grid that should always use the full width, use auto-fit; for a grid where a lone card should stay card-sized, use auto-fill.

Subgrid

A grid item can itself be a grid container, but a nested grid normally has its own tracks that know nothing about the outer grid. Cards side by side then have titles, texts and buttons at different heights, because each card sizes its rows on its own.

Subgrid solves this: with grid-template-rows: subgrid (or grid-template-columns: subgrid), a nested grid uses the tracks of its parent grid instead of defining its own. Each card below spans three rows of the outer grid and lays out its title, text and link on those shared rows, so all titles get the height of the longest title, and so on:

<div class="plans">
  <article class="plan">
    <h3>Green Fixed</h3>
    <p>Wind and solar power at one price for three years.</p>
    <a href="#">Choose Green Fixed</a>
  </article>
  <article class="plan">
    <h3>Dynamic hourly tariff</h3>
    <p>Pay the market price per hour. Cheaper at night and on sunny, windy days; more expensive in winter evenings.</p>
    <a href="#">Choose Dynamic</a>
  </article>
  <article class="plan">
    <h3>Heat</h3>
    <p>District heating.</p>
    <a href="#">Choose Heat</a>
  </article>
</div>
.plans {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}

.plan {
  display: grid;
  grid-row: span 3;               /* take three rows of the outer grid */
  grid-template-rows: subgrid;    /* and use those rows inside */
  gap: 0.5rem;
  border: 1px solid #555;
  padding: 0.75rem;
}

.plan h3,
.plan p {
  margin: 0;
}

The titles, texts and links line up across the three cards even though their text lengths differ. Remove grid-template-rows: subgrid and each card spaces its own content again. A subgrid inherits the parent's gaps unless it sets its own, as the cards do here.

Alignment, and grid versus flexbox

Grid uses the same alignment properties as flexbox. justify-items aligns items along the row (inline) direction inside their cells, align-items along the column (block) direction, and justify-content / align-content move the whole set of tracks when the grid is smaller than its container. The shorthand place-items: center sets both item alignments at once, which makes it the shortest way to center something:

<div class="empty">
  <p>No outages in your area.</p>
</div>
.empty {
  display: grid;
  place-items: center;
  min-height: 8rem;
  border: 1px dashed #555;
}

Choosing between grid and flexbox

Situation Usually the better fit
A row of buttons, a toolbar, a navigation bar, a label next to an icon Flexbox: one direction, sizes come from the content
A page layout with header, sidebar, main and footer Grid: rows and columns at once, named areas
A collection of cards that should form neat columns Grid with auto-fit and minmax()
Tags or chips that wrap and keep their own widths Flexbox with flex-wrap: wrap
A form with labels and fields in aligned columns Grid

The rule of thumb: flexbox works from the content outward (the items decide their sizes), grid works from the layout inward (the container decides the tracks and the items fill them). The two combine freely: a grid item can be a flex container and the other way around.