CSSHoly Grail Layout

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.

Note
All three problems disappear with Grid and Flexbox: tracks are equal-height by nature, 1fr rows absorb leftover height, and placement is independent of source order.
The Grid Solution (recommended)

HTML
<body>
  <header>Header</header>
  <main>Main content first in source</main>
  <nav>Nav</nav>
  <aside>Aside</aside>
  <footer>Footer</footer>
</body>

CSS
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

CSS
@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.

CSS
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; }
}
Note
The flexbox version needs an extra wrapper (.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 .middle wrapper

Content-first source order

Free via areas

Requires order

Sticky footer

1fr middle row

flex: 1 on the wrapper

Responsive re-layout

Redefine one grid-template-areas map

Change direction + several items

Browser support

Everywhere since 2017

Everywhere since ~2014

Complete Copy-Paste Implementation

HTML
<!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>
Tip
Use 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 nav a position: sticky; top: 1rem; align-self: start so it follows the scroll inside its track.

  • Full-height app shell — swap min-height: 100dvh for height: 100dvh plus overflow: auto on main to get an app-style fixed chrome with an internal scroll area.