The Holy-Grail Layout with CSS Grid and Container Queries
Problem statement
The holy-grail layout is the canonical web page shape: a header across the top, a footer across the bottom, and three columns between them, a navigation rail on the left, a flexible content column in the middle, and a complementary sidebar on the right. The classic requirements are that the center column appears first in the source for accessibility and SEO, the side columns hold their useful widths without squeezing the content, the footer sticks to the bottom even on short pages, and the whole thing collapses to a single column on narrow screens. For two decades this required float hacks or fragile flexbox nesting. With CSS Grid and grid-template-areas it is a dozen lines. This page builds that layout with named areas and minmax(), then makes the widgets inside it component-adaptive with @container. It sits under Mastering Container Queries & Responsive Layouts and extends the CSS Grid and Subgrid Layouts guide.
Approach rationale
A JavaScript layout engine or a CSS framework grid is overkill here; the page skeleton is fundamentally a static two-dimensional arrangement, which is precisely what Grid was designed for. grid-template-areas is the right tool over raw line numbers because the template strings are a literal ASCII picture of the page, so the layout is readable at a glance and re-mappable per breakpoint without touching markup. That decoupling of visual position from source order is the key accessibility win: you author the HTML in reading order (content first) and let the grid paint it wherever it belongs, so keyboard and screen-reader users get a sane traversal regardless of the visual column order.
minmax() earns its place because side columns have a usability range, not a single correct width. A pure fr track would let the nav shrink to nothing on medium screens; a fixed rem width would not flex at all. minmax() expresses the real constraint. Where this approach has limits: do not reach for container queries to collapse the columns, because the page skeleton responds to the viewport, not to a component's width. Use viewport media queries for the macro layout and reserve container queries for the widgets inside the regions.
Complete working implementation
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Holy-grail layout</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: #1c2430; }
.page {
display: grid;
min-height: 100dvh; /* Footer sticks to the bottom on short pages. */
grid-template-columns:
minmax(12rem, 16rem) /* nav: never too narrow, never too wide */
1fr /* main: absorbs remaining space */
minmax(10rem, 14rem); /* aside: clamped complementary column */
grid-template-rows: auto 1fr auto; /* header, content band, footer */
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
}
.page__header { grid-area: header; background: #1c2a4a; color: #fff; padding: 1rem 1.5rem; }
.page__nav { grid-area: nav; background: #eef2fa; padding: 1rem; }
.page__main { grid-area: main; padding: 1.5rem; }
.page__aside { grid-area: aside; background: #f6f4ee; padding: 1rem; }
.page__footer { grid-area: footer; background: #1c2a4a; color: #fff; padding: 1rem 1.5rem; }
/* Collapse to a single column on narrow viewports by re-mapping
the SAME areas — no markup change required. */
@media (max-width: 48rem) {
.page {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"main"
"nav"
"aside"
"footer";
}
}
</style>
</head>
<body>
<div class="page">
<header class="page__header">Site header</header>
<!-- Content first in source for accessibility; grid paints it center. -->
<main class="page__main">
<h1>Main content</h1>
<p>The center column comes first in the DOM but renders between the rails.</p>
</main>
<nav class="page__nav" aria-label="Primary">Navigation</nav>
<aside class="page__aside">Sidebar</aside>
<footer class="page__footer">Site footer</footer>
</div>
</body>
</html>
Two details make this robust. min-height: 100dvh plus the auto 1fr auto row template gives the classic sticky footer: the middle row stretches to fill leftover height so the footer is pinned to the bottom even when content is short. And the narrow-viewport media query re-declares only grid-template-areas and grid-template-columns, re-mapping the identical markup into a single stacked column. The <main> is first in source but placed in the center visually, satisfying the accessibility requirement without order tricks.
Key technique callout
The mechanism that makes this layout both readable and re-mappable is the relationship between grid-template-areas and grid-area. Each child names the region it occupies with grid-area, and the parent's grid-template-areas strings spell out a grid of those names, one string per row, one token per column. The browser matches names to regions, so the template is a literal map of the page. Changing the layout, including collapsing three columns to one, means rewriting the strings, never the HTML. Repeating a name across cells (as header spans all three columns) merges those cells into one rectangular area automatically. This name-based placement is what lets the same DOM serve a desktop three-column shape and a mobile single-column stack from CSS alone.
Variation or extension: container-adaptive widgets inside the columns
The page skeleton responds to the viewport, but the widgets living in the nav and aside should respond to the width of their own column, which varies with the minmax() tracks. Declare containment on a region and let its contents reflow per column width, exactly the pattern from the Container Query Syntax Basics guide.
.page__aside { container-type: inline-size; container-name: rail; }
/* A promo card stacks by default, goes side-by-side when the rail is wide. */
.rail-card { display: grid; gap: 0.5rem; }
@container rail (min-width: 13rem) {
.rail-card {
grid-template-columns: 3rem 1fr;
align-items: center;
column-gap: 0.75rem;
}
}
For an RTL variant, none of the area logic changes: because the layout uses logical column placement, switching the document to direction: rtl mirrors the nav and aside automatically, with the header/footer spans unaffected.
Browser support note
Every feature used here is universally available: CSS Grid and grid-template-areas ship in Chrome/Edge 57+, Firefox 52+, and Safari 10.1+, and minmax() arrived alongside Grid. The 100dvh dynamic viewport unit is supported in Chrome/Edge 108+, Safari 15.4+, and Firefox 101+; if you must support older versions, provide a min-height: 100vh fallback before the 100dvh declaration. The @container enhancement for the rail widgets is baseline since 2023.
FAQ
What is the holy-grail layout in CSS? It is a classic page structure with a full-width header and footer plus three middle columns: a fixed-ish left navigation, a flexible center content area, and a fixed-ish right sidebar. CSS Grid solves it cleanly with named template areas.
Why use grid-template-areas instead of line numbers for the holy-grail layout?
Named areas make the layout self-documenting: the template strings visually map to the page, and reordering regions is a one-line change. They also let you re-map the same markup to different shapes at each breakpoint without touching the HTML.
How does minmax() keep the side columns usable?minmax(min, max) clamps a track between a floor and a ceiling. Giving the nav minmax(12rem, 16rem) means it never collapses below a readable width and never grows past its useful maximum, while the center column on 1fr absorbs the rest.
Can container queries change the holy-grail layout? They change components inside it rather than the page skeleton itself. Use viewport media queries to collapse the three columns to one on small screens, and container queries to adapt the internals of widgets that live in the columns.
Related
- CSS Grid and Subgrid Layouts — the parent guide on track sizing and subgrid.
- Building a Dashboard with Subgrid — align card internals across a grid with subgrid.
- Container vs Media Queries Comparison — when to use viewport versus container breakpoints.
- Smooth Hover Effects Without JavaScript — add polish to nav links in the rail.
Related articles
More pages in the same section.