The Holy Grail Layout
The holy grail is the classic page skeleton: a header, a footer, and a middle band of three columns — navigation, main content, and an aside — where all three columns are equal height, the footer sticks to the bottom on short pages, and the source order keeps the main content first. For a decade this was genuinely hard. With modern CSS it takes a dozen lines.
Why It Was Hard (a little history)
In the float era (roughly 2000–2015), the layout required negative margins, padding hacks, and faux-column background tricks — the famous 2006 A List Apart article "In Search of the Holy Grail" documented one working recipe and gave the layout its name. The pain points were:
Floats have no concept of equal-height columns — backgrounds ended wherever the content did.
Keeping the footer at the bottom on short pages required height hacks like the "100% minus footer" pattern.
Putting main content first in the source (better for SEO and screen readers) while displaying nav on the left needed negative-margin gymnastics.
1fr rows absorb leftover height, and placement is independent of source order.The Grid Solution (recommended)
<body> <header>Header</header> <main>Main content first in source</main> <nav>Nav</nav> <aside>Aside</aside> <footer>Footer</footer> </body>
body {
display: grid;
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
grid-template-columns: 200px minmax(0, 1fr) 200px;
grid-template-rows: auto 1fr auto; /* 1fr = sticky footer built in */
min-height: 100dvh;
gap: 1rem;
margin: 0;
}
header { grid-area: header; }
nav { grid-area: nav; }
main { grid-area: main; }
aside { grid-area: aside; }
footer { grid-area: footer; }grid-template-rows: auto 1fr auto— header and footer take their natural height, the middle row absorbs everything else. That is the sticky footer.minmax(0, 1fr)for the main column stops wide content (tables, code) from stretching the layout.Source order is header → main → nav → aside → footer; the template places them visually with nav on the left regardless.
Columns are automatically equal height — every track in a row shares the same height.
Responsive Collapse
@media (max-width: 768px) {
body {
grid-template-areas:
"header"
"nav"
"main"
"aside"
"footer";
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr auto auto;
}
}
/* Redefine the map, done. No element-level overrides needed —
this is the superpower of grid-template-areas. */The Flexbox Solution
Before Grid support was universal, nested flexbox was the standard answer. It still works fine and shows how the two models differ.
body {
display: flex;
flex-direction: column;
min-height: 100dvh;
margin: 0;
}
.middle {
display: flex;
flex: 1; /* sticky footer: middle band grows */
gap: 1rem;
}
main { flex: 1; min-width: 0; order: 2; }
nav { flex: 0 0 200px; order: 1; } /* main can stay first in HTML */
aside { flex: 0 0 200px; order: 3; }
@media (max-width: 768px) {
.middle { flex-direction: column; }
nav, aside { flex-basis: auto; }
}.middle) around the three columns, and it uses order to reconcile source order with visual order. The grid version needs neither — which is why Grid is the modern default for this layout.Grid vs Flexbox for the Holy Grail
Aspect | Grid | Flexbox |
|---|---|---|
Extra wrapper element | Not needed | Needs a |
Content-first source order | Free via areas | Requires |
Sticky footer |
|
|
Responsive re-layout | Redefine one | Change direction + several items |
Browser support | Everywhere since 2017 | Everywhere since ~2014 |
Complete Copy-Paste Implementation
<!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; }
body {
margin: 0;
font-family: system-ui, sans-serif;
display: grid;
grid-template-areas:
"header header header"
"nav main aside"
"footer footer footer";
grid-template-columns: 200px minmax(0, 1fr) 200px;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
gap: 1rem;
}
header { grid-area: header; background: #1e293b; color: #fff; padding: 1rem; }
nav { grid-area: nav; background: #f1f5f9; padding: 1rem; }
main { grid-area: main; padding: 1rem; }
aside { grid-area: aside; background: #f1f5f9; padding: 1rem; }
footer { grid-area: footer; background: #1e293b; color: #fff; padding: 1rem; }
@media (max-width: 768px) {
body {
grid-template-areas: "header" "nav" "main" "aside" "footer";
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr auto auto;
}
}
</style>
</head>
<body>
<header>Site header</header>
<main>
<h1>Main content</h1>
<p>First in the source, best for SEO and screen readers.</p>
</main>
<nav>Navigation</nav>
<aside>Sidebar / ads</aside>
<footer>Footer — pinned to the bottom on short pages</footer>
</body>
</html>100dvh rather than 100vh for the minimum height: on mobile browsers 100vh includes the space behind the collapsing address bar, which can push the footer off-screen. dvh tracks the actual visible viewport.Variations Worth Knowing
Two-column grail — drop the aside:
grid-template-columns: 220px minmax(0, 1fr)and a 2-column area map. See the sidebar layout page.Sticky nav — give
navaposition: sticky; top: 1rem; align-self: startso it follows the scroll inside its track.Full-height app shell — swap
min-height: 100dvhforheight: 100dvhplusoverflow: autoonmainto get an app-style fixed chrome with an internal scroll area.
Related pages: grid-template-areas, Sticky footer, and the Grid cheatsheet.