CSS Module 11 – CSS Grid
Grid areas and named lines
Draw a layout as text with grid-template-areas, give grid lines names, and rearrange areas at breakpoints without changing the HTML or its reading order.
What you will learn
- Describe a layout visually with grid-template-areas
- Name grid lines and place items with them
- Move areas at breakpoints without touching the HTML
Before you start
What you’ll learn · Before you start
What you will learn
- Describe a layout visually with grid-template-areas
- Name grid lines and place items with them
- Move areas at breakpoints without touching the HTML
Before you start
On this page
grid-template-areas lets you draw a layout as text. Each string is a row and each word in it a cell, so
"header header" "nav main" is a header across two columns above a navigation column and the main content.
Items join an area by name with grid-area: header, and the browser works out the rows, columns and spans. Naming
also works for lines: [content-start] in a track list gives a line a name, and an area named main gives you the
lines main-start and main-end as well. The value of areas is that a layout change at a breakpoint becomes a new
drawing, written next to the old one, while the HTML stays as it is. The cost is that visual order and reading order
can drift apart, and the measurements here, from Chromium 153, Firefox 155 and WebKit 26.6, show by how much.
Drawing the layout in text
The page below has a header, a navigation, the main content, related links and a footer, in that order in the HTML. Each item names its area, and each breakpoint draws a new map of areas:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One page, three layouts</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.page { display: grid; gap: 16px; }
.page > * { background: #dde; }
.site-header { grid-area: header; block-size: 64px; }
.site-nav { grid-area: nav; }
.content { grid-area: main; block-size: 200px; }
.related { grid-area: aside; block-size: 120px; }
.site-footer { grid-area: footer; block-size: 48px; }
/* Phones: one column, in the order of the HTML. */
.page {
grid-template-areas:
"header"
"nav"
"main"
"aside"
"footer";
}
/* From 768px: the navigation beside the content. */
@media (min-width: 48rem) {
.page {
grid-template-columns: 12rem 1fr;
grid-template-areas:
"header header"
"nav main"
"nav aside"
"footer footer";
}
}
/* From 1200px: three columns. */
@media (min-width: 75rem) {
.page {
grid-template-columns: 12rem 1fr 16rem;
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
}
}
</style>
</head>
<body>
<div class="page">
<header class="site-header">Header</header>
<nav class="site-nav">Navigation</nav>
<main class="content">Main content</main>
<aside class="related">Related</aside>
<footer class="site-footer">Footer</footer>
</div>
<script>
// Each area's rectangle at the current width: left edge, top edge, width and height, in px.
const round = (n) => Math.round(n * 100) / 100;
console.log(`viewport ${innerWidth}px: areas ${getComputedStyle(document.querySelector('.page')).gridTemplateAreas}`);
for (const item of document.querySelector('.page').children) {
const r = item.getBoundingClientRect();
console.log(` ${item.textContent}: x ${round(r.x)}, y ${round(r.y)}, ${round(r.width)} × ${round(r.height)}`);
}
</script>
</body>
</html> The lesson's page at three widths, and the strings that draw it
Text description of the diagram
Three wireframes of the lesson's page, one above the other, each with the strings of its grid-template-areas value beside it, one string per row.
- Below 768 px there is one column: header, nav, main, aside and footer are stacked in that order. The strings are "header", "nav", "main", "aside" and "footer".
- From 768 px there are two columns. The header and the footer run across both. The nav fills the left column of rows 2 and 3, while main sits to its right in row 2 and aside below main in row 3. The strings are "header header", "nav main", "nav aside" and "footer footer": repeating nav in two rows makes it two rows tall.
- From 1200 px there are three columns. The header and footer run across all three, and row 2 holds nav, main and aside side by side. The strings are "header header header", "nav main aside" and "footer footer footer".
Under the wireframes, a note says that the HTML order stays header, nav, main, aside, footer in every layout: only the CSS changes.
The three engines drew identical rectangles at each width, with 16 px gaps. At 360 px every area was 360 px wide, stacked in the HTML order and 64, 24, 200, 120 and 48 px tall. The two wider layouts, width × height in px:
| Area | 900 px | 1440 px |
|---|---|---|
| header | 900 × 64 | 1440 × 64 |
| nav | 192 × 336 | 192 × 200 |
| main | 692 × 200 | 960 × 200 |
| aside | 692 × 120 | 256 × 120 |
| footer | 900 × 48 | 1440 × 48 |
At 900 px the content starts 208 px from the left, after the 192 px navigation and one gap, and the related links sit
under it at 296 px from the top; at 1440 px they move up beside it, 1184 px from the left. Repeating nav in two rows
of the 900 px map
made the navigation two rows tall, 200 + 16 + 120 = 336 px, beside both the content and the related links. At 1440 px
the same five items sit in three columns, and nothing in the HTML changed. The rows were never sized: tracks that a
template creates take their size from grid-auto-rows, which is auto unless you set it, so each row fitted its
tallest item.
The rules a template must follow
Four rules decide whether the browser accepts a template:
- Every string is one row, and every row has the same number of cells. Spaces separate the cells; extra spaces only line the columns up for the reader.
- A name repeated in neighbouring cells makes one area, across columns, down rows, or both.
- A run of dots is an empty cell.
.and...both mean one cell with no area in it. - Every area must be a rectangle. An L shape, or the same name in two separate places, is not allowed.
Breaking a rule does not produce a half-right layout: the whole declaration is invalid and ignored, as if it had
never been written. One more mistake is valid CSS and fails in another way. Three grids, each 400 px wide with
two 1fr columns, 8 px gaps, and 100 by 40 px implicit tracks:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Three ways to break grid-template-areas</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.grid {
display: grid;
inline-size: 400px;
grid-template-columns: 1fr 1fr;
grid-auto-rows: 40px;
grid-auto-columns: 100px;
gap: 8px;
margin-block-end: 24px;
}
.grid > * { background: #dde; }
/* 1. Not a rectangle: "a" is L-shaped. */
#l-shape { grid-template-areas: "a a" "a b"; }
#l-shape .one { grid-area: a; }
#l-shape .two { grid-area: b; }
/* 2. Ragged rows: three cells in the first row, two in the second. */
#ragged { grid-template-areas: "a a b" "c c"; }
#ragged .one { grid-area: a; }
#ragged .two { grid-area: c; }
/* 3. A valid template, but the item asks for an area that does not exist. */
#typo { grid-template-areas: "header header" "main side"; }
#typo .one { grid-area: header; }
#typo .two { grid-area: mian; } /* "main", mistyped */
</style>
</head>
<body>
<div class="grid" id="l-shape"><div class="one">a</div><div class="two">b</div></div>
<div class="grid" id="ragged"><div class="one">a</div><div class="two">c</div></div>
<div class="grid" id="typo"><div class="one">header</div><div class="two">mian</div></div>
<script>
const round = (n) => Math.round(n * 100) / 100;
for (const grid of document.querySelectorAll('.grid')) {
const style = getComputedStyle(grid);
const box = grid.getBoundingClientRect();
console.log(`${grid.id}: grid-template-areas is ${style.gridTemplateAreas}; columns ${style.gridTemplateColumns}; rows ${style.gridTemplateRows}`);
for (const item of grid.children) {
const r = item.getBoundingClientRect();
console.log(` item ${item.textContent}: x ${round(r.x - box.x)}, y ${round(r.y - box.y)}, ${round(r.width)} × ${round(r.height)}`);
}
console.log(` the grid is ${round(box.width)}px wide and its content ${grid.scrollWidth}px`);
}
</script>
</body>
</html> What all three engines did:
| Template | Computed value | Where the items went |
|---|---|---|
| An L-shaped area | none |
Both into one new cell |
| Rows of 3 and 2 cells | none |
Both into one new cell |
| Valid, one name mistyped | the template | A new row 4 and column 4 |
In the first two grids the computed value of grid-template-areas was none, and both items landed in the same new
cell, at row 2 and column 4, one on top of the other.
The last row is the bug you will meet in real code. No area is called “mian”, so the browser looks for lines called
mian-start and mian-end, finds none, and uses the first line after the explicit grid instead, in both directions.
The item ended up below and to the right of the layout, two empty tracks appeared to reach it, and the two real
columns shrank from 196 to 88 px to make room. When a layout suddenly grows a stray row and column, check every
grid-area name against the template first. A program that reads templates the way a browser does:
// What a browser reads from grid-template-areas (CSS Grid Level 2): each string is a row, each word a cell, a run of
// dots an empty cell. Every row needs the same number of cells and every name must cover a rectangle, or the whole
// declaration is invalid. Each area gives an item its lines: <name>-start and <name>-end in both directions.
function areasOf(rows) {
const grid = rows.map((row) => row.trim().split(/\s+/).map((cell) => (/^\.+$/.test(cell) ? null : cell)));
if (grid.some((cells) => cells.length !== grid[0].length)) {
throw new Error(`rows of ${grid.map((cells) => cells.length).join(' and ')} cells`);
}
const areas = new Map();
grid.forEach((cells, r) => cells.forEach((name, c) => {
if (!name) return;
const a = areas.get(name) ?? { top: r, bottom: r, left: c, right: c, cells: 0 };
areas.set(name, { top: Math.min(a.top, r), bottom: Math.max(a.bottom, r), left: Math.min(a.left, c), right: Math.max(a.right, c), cells: a.cells + 1 });
}));
for (const [name, a] of areas) {
if (a.cells !== (a.bottom - a.top + 1) * (a.right - a.left + 1)) throw new Error(`"${name}" is not a rectangle`);
}
return areas;
}
const layouts = {
'phones': ['header', 'nav', 'main', 'aside', 'footer'],
'from 768px': ['header header', 'nav main', 'nav aside', 'footer footer'],
'from 1200px': ['header header header', 'nav main aside', 'footer footer footer'],
'L-shaped': ['a a', 'a b'],
'ragged': ['a a b', 'c c'],
};
for (const [label, rows] of Object.entries(layouts)) {
try {
const areas = areasOf(rows);
const list = [...areas].map(([name, a]) => `${name} ${a.top + 1} / ${a.left + 1} / ${a.bottom + 2} / ${a.right + 2}`);
console.log(`${label}: ${list.join(', ')}`);
} catch (error) {
console.log(`${label}: invalid, ${error.message}; the declaration is ignored`);
}
} Output
phones: header 1 / 1 / 2 / 2, nav 2 / 1 / 3 / 2, main 3 / 1 / 4 / 2, aside 4 / 1 / 5 / 2, footer 5 / 1 / 6 / 2 from 768px: header 1 / 1 / 2 / 3, nav 2 / 1 / 4 / 2, main 2 / 2 / 3 / 3, aside 3 / 2 / 4 / 3, footer 4 / 1 / 5 / 3 from 1200px: header 1 / 1 / 2 / 4, nav 2 / 1 / 3 / 2, main 2 / 2 / 3 / 3, aside 2 / 3 / 3 / 4, footer 3 / 1 / 4 / 4 L-shaped: invalid, "a" is not a rectangle; the declaration is ignored ragged: invalid, rows of 3 and 2 cells; the declaration is ignored
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node areas.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The four numbers it prints for each area are row start, column start, row end and column end: the same order you
would write in grid-area: 2 / 1 / 4 / 2 to place an item by lines instead of by name.
Visual order and reading order
Areas move boxes on screen. They do not move anything in the HTML, which is the order that keyboard focus and screen readers follow. That is fine when the move does not change the meaning, as when the navigation goes beside the content on a wide screen. It becomes a problem when the order people read in and the order they navigate in disagree. A common phone design puts the content first and the navigation after it:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Navigation moved below the content on phones</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.page { display: grid; gap: 16px; }
.page > * { background: #dde; padding: 8px; }
.site-header { grid-area: header; }
.site-nav { grid-area: nav; }
.content { grid-area: main; block-size: 400px; }
.site-footer { grid-area: footer; }
/* Phones: the content first, the navigation after it. The HTML is not changed. */
.page {
grid-template-areas:
"header"
"main"
"nav"
"footer";
}
</style>
</head>
<body>
<div class="page">
<header class="site-header"><a href="#home">Home</a></header>
<nav class="site-nav"><a href="#shop">Shop</a> <a href="#offers">Offers</a></nav>
<main class="content"><a href="#product">The product you came for</a></main>
<footer class="site-footer"><a href="#help">Help</a></footer>
</div>
</body>
</html> At 360 px wide, Tab was pressed five times from the top of the page (Option-Tab in WebKit):
| Link, in Tab order | From the top |
|---|---|
| Home | 11 px |
| Shop | 499 px |
| Offers | 499 px |
| The product you came for | 67 px |
| Help | 555 px |
All three engines did exactly this. A keyboard user who can see the screen is taken from the header past the whole content to the menu at the bottom, then back up to the product, which is what WCAG 2.2 success criteria 1.3.2 (Meaningful Sequence) and 2.4.3 (Focus Order) warn about, and what the Grid specification means when it says placement is for visual, not logical, reordering. Two fixes keep the orders together. If the content should come first on every screen, put it first in the HTML and draw the areas around that order. If the navigation belongs at the top, keep it at the top on phones too, made compact, for example as a single menu button.
Naming lines
Line numbers work, but names survive changes to a track list. Put names in square brackets between the track sizes, and place items between them. A classic use is an article whose text keeps a readable width while some elements run from edge to edge:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A full-bleed article with named lines</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.article {
display: grid;
row-gap: 16px;
grid-template-columns:
[full-start] minmax(1rem, 1fr)
[content-start] minmax(0, 40rem) [content-end]
minmax(1rem, 1fr) [full-end];
}
.article > * { grid-column: content; margin: 0; background: #dde; } /* content-start to content-end */
.article > .full-bleed { grid-column: full; } /* full-start to full-end */
</style>
</head>
<body>
<article class="article">
<h1>A heading</h1>
<p>A paragraph of the article, kept to a readable width.</p>
<figure class="full-bleed">A photo that runs from edge to edge</figure>
<p>The text goes on at the same width as before.</p>
</article>
<script>
const round = (n) => Math.round(n * 100) / 100;
const article = document.querySelector('.article');
console.log(`viewport ${innerWidth}px: columns ${getComputedStyle(article).gridTemplateColumns}`);
for (const item of article.children) {
const r = item.getBoundingClientRect();
console.log(` ${item.tagName.toLowerCase()}${item.className ? '.' + item.className : ''}: x ${round(r.x)}, width ${round(r.width)}`);
}
</script>
</body>
</html> | At | Columns, px | Text, figure |
|---|---|---|
| 360 px | 16 · 328 · 16 | 328, 360 px |
| 1440 px | 400 · 640 · 400 | 640, 1440 px |
All three engines agreed: the text started 16 px from the left on the phone and 400 px on the wide screen, and the
figure started at 0 on both. The middle column grows to its 40rem limit first and the two side columns share what is left,
never less than 1rem each, so the text never touches the edges of a phone. grid-column: content works because of
another naming rule: lines called content-start and content-end make an area called content, just as an area
named main makes lines called main-start and main-end. Two more details matter. A line can carry several names,
as in [content-end gutter-start]. And the first run of this page gave the figure a 40 px margin on each side, because
browsers give figure side margins by default; grid items keep their margins, which do not collapse into the gaps, so
the example resets them to 0.
The grid-template shorthand
grid-template sets the rows, the columns and the areas in one declaration, with each row’s size after its string and
the column sizes after a slash. It is compact and hard to edit: changing one column means rewriting a block that mixes
three properties. The longhands are friendlier in code that a team maintains. The grid shorthand goes further and
also resets grid-auto-rows, grid-auto-columns and grid-auto-flow, which can undo settings made elsewhere.
/* The 768px layout written with longhands: each part can be read and changed on its own. */
.page {
grid-template-columns: 12rem 1fr;
grid-template-rows: auto 1fr auto auto;
grid-template-areas:
"header header"
"nav main"
"nav aside"
"footer footer";
}
/* The same layout with the grid-template shorthand: each row's size follows its string, and the column sizes come
last, after a slash. */
.page {
grid-template:
"header header" auto
"nav main" 1fr
"nav aside" auto
"footer footer" auto
/ 12rem 1fr;
} Practise: a dashboard in three layouts
Exercise · Medium · JavaScript
Draw a dashboard's three layouts with grid areas
A dashboard has five parts, named title, kpis, chart, table and alerts, in that order in its HTML. Its three wireframes, written as rows of cells from top to bottom:
- Below 600 px: one column, the five parts stacked in the HTML order. - From 600 px to 1023 px: two columns. Row 1: title across both. Row 2: kpis, then alerts. Row 3: chart across both. Row 4: table across both. - From 1024 px: three columns. Row 1: title across all three. Row 2: kpis, chart, then alerts. Row 3: table across the first two columns, while alerts carries on down the third.
Write two functions in dashboard.mjs and export them.
The first, dashboardAreas(width), returns the value of grid-template-areas for a viewport width px wide, as an array of strings, one per row (["title", "kpis", …] for the phone layout).
The second, placeOf(rows, name), returns where the browser puts an item with grid-area: <name> for such an array: { row: [start, end], column: [start, end] }, the lines the area runs between (line 1 is the top or left edge). Like a browser, it returns null when the template is invalid (rows with different numbers of cells, or a name whose cells do not form one rectangle) or when no cell has that name. A run of dots, such as . or ..., is one empty cell.
The sample tests import both functions from dashboard.mjs and run in your browser.
Starter code · dashboard.mjs
/** The value of grid-template-areas for a viewport `width` px wide: one string per row. */
export function dashboardAreas(width) {
// Replace this line with your code.
return ['title kpis chart table alerts'];
}
/** The lines an item with grid-area: <name> sits between, or null. */
export function placeOf(rows, name) {
// Replace this line with your code.
return null;
} The sample tests · dashboard.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { dashboardAreas, placeOf } from './dashboard.mjs';
const at = (width) => (name) => placeOf(dashboardAreas(width), name);
test('placeOf reads names, spans and empty cells', () => {
const rows = ['header header', 'nav main', '. footer'];
assert.deepEqual(placeOf(rows, 'header'), { row: [1, 2], column: [1, 3] });
assert.deepEqual(placeOf(rows, 'nav'), { row: [2, 3], column: [1, 2] });
assert.deepEqual(placeOf(rows, 'footer'), { row: [3, 4], column: [2, 3] });
assert.deepEqual(placeOf(['a ... b'], 'b'), { row: [1, 2], column: [3, 4] });
assert.equal(placeOf(rows, 'aside'), null);
});
test('placeOf refuses what a browser refuses', () => {
assert.equal(placeOf(['a a', 'a b'], 'b'), null);
assert.equal(placeOf(['a a b', 'c c'], 'a'), null);
assert.deepEqual(placeOf(['a a', 'a a'], 'a'), { row: [1, 3], column: [1, 3] });
});
test('the phone layout at 360px', () => {
const place = at(360);
assert.deepEqual(place('title'), { row: [1, 2], column: [1, 2] });
assert.deepEqual(place('kpis'), { row: [2, 3], column: [1, 2] });
assert.deepEqual(place('chart'), { row: [3, 4], column: [1, 2] });
assert.deepEqual(place('table'), { row: [4, 5], column: [1, 2] });
assert.deepEqual(place('alerts'), { row: [5, 6], column: [1, 2] });
});
test('the two-column layout at 600 and 900px', () => {
for (const width of [600, 900]) {
const place = at(width);
assert.deepEqual(place('title'), { row: [1, 2], column: [1, 3] });
assert.deepEqual(place('kpis'), { row: [2, 3], column: [1, 2] });
assert.deepEqual(place('alerts'), { row: [2, 3], column: [2, 3] });
assert.deepEqual(place('chart'), { row: [3, 4], column: [1, 3] });
assert.deepEqual(place('table'), { row: [4, 5], column: [1, 3] });
}
});
test('the three-column layout at 1024 and 1440px', () => {
for (const width of [1024, 1440]) {
const place = at(width);
assert.deepEqual(place('title'), { row: [1, 2], column: [1, 4] });
assert.deepEqual(place('kpis'), { row: [2, 3], column: [1, 2] });
assert.deepEqual(place('chart'), { row: [2, 3], column: [2, 3] });
assert.deepEqual(place('alerts'), { row: [2, 4], column: [3, 4] });
assert.deepEqual(place('table'), { row: [3, 4], column: [1, 3] });
}
});
test('just below each breakpoint', () => {
assert.equal(dashboardAreas(599).length, 5);
assert.equal(dashboardAreas(1023).length, 4);
}); A hint
Split each string on whitespace to get its cells, and check that every row has as many cells as the first. For a name, collect the row and column of every cell with that name: its area runs from the smallest row to the largest row plus 1, and the same for columns. It is a rectangle only if the number of cells equals rows × columns.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Interview questions
Fresher · What do the strings in grid-template-areas describe, and what does a dot mean?
Each string is one row of the grid, and each word in it is one cell, so the strings draw the layout as a small map.
Writing the same name in neighbouring cells makes one named area that spans them, and an item joins it with grid-area: name. A dot, or a run of dots such as ..., is a cell that belongs to no area; items placed automatically can still
land there. The browser accepts the map only if every row has the same number of cells and every area is a rectangle;
otherwise the whole declaration is ignored. Rows created this way take their size from grid-auto-rows unless
grid-template-rows sizes them. Areas also give each item lines named <name>-start and <name>-end, and they change
only what you see, never the HTML order that Tab and screen readers follow.
Key takeaways
- Each string of
grid-template-areasis a row, each word a cell; repeat a name to span, use dots for empty cells. - Rows with different numbers of cells, or an area that is not a rectangle, make the whole declaration invalid.
- A mistyped
grid-areaname is valid CSS: the item lands in new tracks after the explicit grid and squeezes the rest. - Areas create lines named
<name>-startand<name>-end, and lines with those names create an area. - Named lines such as
[content-start]keep placements readable and stable when tracks change. - Moving areas changes only the visual order; keep the HTML in the order people should read and tab through.
- Prefer the longhands to
grid-templateandgridin shared code;gridalso resets the implicit-grid properties.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- CSS Grid Layout Module Level 2: named areas, the grid-template-areas property (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: implicitly-assigned line names (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: implicitly-named areas (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: naming grid lines (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: line-based placement (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: placement shorthands (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: implicit track sizing (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: the grid-template shorthand (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: the grid shorthand (W3C CSS Working Group)
- CSS Grid Layout Module Level 2: reordering and accessibility (W3C CSS Working Group)
- grid-template-areas (MDN Web Docs) (Mozilla)
- Understanding Success Criterion 1.3.2: Meaningful Sequence (W3C Web Accessibility Initiative)
- Understanding Success Criterion 2.4.3: Focus Order (W3C Web Accessibility Initiative)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress