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.

Flexbox is the obvious alternative and it is the wrong one at this scale. Three columns plus a full-bleed header and footer is a two-dimensional problem, and Flexbox only reasons about one axis at a time, so a Flexbox holy grail means nesting a row inside a column and hand-managing flex-basis on every rail. Grid places all five regions in one declaration, and — the part Flexbox genuinely cannot do — it lets a region be named and addressed as a rectangle rather than as the Nth child in a line. That naming is what keeps the markup in reading order while the paint order differs; the accessibility consequences of divergence between the two are worked through in detail on container query sidebar layouts.

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.

One scoping note, because the two look similar in a stylesheet. This page uses minmax() for explicit, named tracks whose count is fixed at three and whose identity is known in advance — nav, main, aside. That is a different job from generating an unknown number of equal tracks, which is what auto-fit and minmax responsive grids covers with repeat(auto-fit, minmax(…, 1fr)). A page skeleton has named regions and must never reflow them into an arbitrary column count; a card gallery has interchangeable items and should. Reach for repeat(auto-fit, …) inside main, not for main itself.

Holy-grail layout area map A labeled diagram showing a full-width header, a full-width footer, and three middle columns named nav, main, and aside, sized with minmax on the sides and 1fr in the center. grid-template-areas map header nav minmax(12,16rem) main 1fr (content first) aside minmax(10,14rem) footer

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.

Read the row template as the load-bearing half of the sticky footer. auto sizes the header to its content, the second 1fr claims every remaining pixel of the 100dvh minimum, and the final auto sizes the footer to its content. If content overflows the viewport the 1fr row simply grows past its share and the footer lands below the fold, which is the behaviour you want — the footer is pinned to the bottom of the page, not stuck to the bottom of the screen. That distinction is why this needs no position: fixed and therefore never covers content.

Gutters deserve a decision rather than a default. Adding gap: 1.5rem to .page inserts space between all five regions, including between the header and the column band, which usually reads as a seam in a full-bleed header. The cleaner arrangement is column-gap: 1.5rem alone, so the three columns breathe while the header and footer stay flush against them. If you want an intentionally empty region — say a blank cell where the aside would sit on a particular template — spell it with a . token in the area strings rather than deleting the column; the track still exists and the remaining regions keep their positions.

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.

The second extension worth adding is a rail that stays visible while the centre column scrolls. Because each region is a grid item stretched to the full height of its row, a rail cannot simply take position: sticky — a sticky element needs a scroll distance inside its own containing block, and a full-height grid item has none. Give the rail an inner wrapper and stick that instead.

.page__nav { padding: 0; }

.page__nav > .rail-inner {
  position: sticky;
  top: 0;
  /* Never taller than the screen, so long rails can scroll internally
     instead of hiding their last items off-screen. */
  max-height: 100dvh;
  overflow-y: auto;
  padding: 1rem;
}

/* In the stacked single-column template the rail is short and sits
   between other content, where sticking it would be obtrusive. */
@media (max-width: 48rem) {
  .page__nav > .rail-inner { position: static; max-height: none; }
}

Turning stickiness off in the stacked template matters more than it looks. In one column the nav is a normal band of content, and a sticky band that follows the reader down a phone screen eats scarce vertical space for a region they have already passed.

Browser support note

Every feature used here is universally available. CSS Grid, grid-template-areas, and minmax() all shipped together in the original Grid implementation years ago and need no gate. The 100dvh dynamic viewport unit came later but is supported across every current engine; if you must support builds that predate it, provide a min-height: 100vh fallback before the 100dvh declaration. The @container enhancement for the rail widgets needs Chrome and Edge 105+, Firefox 110+, or Safari 16+, and simply leaves the rail widget in its stacked default anywhere older.

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 articles

More pages in the same section.