Type something to search...
The Holy Grail Layout with Modern CSS

The Holy Grail Layout with Modern CSS

The Holy Grail layout earned its name in the mid-2000s because it was so hard to build. The requirements sound simple: a header across the top, a footer across the bottom, a fluid main content column in the middle, and a fixed-width sidebar on each side. On top of that, the footer should sit at the bottom of the window even when the content is short, all three columns should be equal height, and the main content should come first in the HTML for accessibility and SEO.

Meeting all of those at once with floats required negative margins, relative positioning, padding tricks, and sometimes faux-column background images. Entire articles were devoted to it. With modern CSS, the whole thing takes about a dozen lines. In this guide, we'll build the Holy Grail with CSS Grid, then make it responsive, look at a Flexbox alternative, and cover the modern refinements that make it production-ready.

The Requirements

Let's be precise about what we're building:

  1. A header spanning the full width.
  2. Three columns: a left sidebar (navigation), a main content area, and a right sidebar (ads, related links, or a table of contents).
  3. Sidebars have a fixed or bounded width; the main column is fluid.
  4. All three columns are equal height.
  5. A footer spanning the full width, pushed to the bottom of the viewport when content is short (a "sticky footer").
  6. The main content comes first in the source order.
  7. On small screens, the columns stack in a sensible order.

The HTML

<body>
  <div class="holy-grail">
    <header class="hg-header">
      <a href="/" class="logo">TideWave</a>
    </header>

    <main class="hg-main" id="content">
      <h1>Understanding the cascade</h1>
      <p>...</p>
    </main>

    <nav class="hg-nav" aria-label="Documentation">
      <ul>
        <li><a href="/docs/selectors">Selectors</a></li>
        <li><a href="/docs/cascade">Cascade</a></li>
        <li><a href="/docs/layout">Layout</a></li>
      </ul>
    </nav>

    <aside class="hg-aside" aria-label="Related articles">
      <h2>Related</h2>
      <ul>
        <li><a href="/blog/specificity">Specificity explained</a></li>
      </ul>
    </aside>

    <footer class="hg-footer">
      <p>&copy; 2026 TideWave</p>
    </footer>
  </div>
</body>

Notice that main comes before nav and aside in the source. With the old float techniques, getting this order while displaying the nav on the left was the hardest part. With Grid, source order and visual placement are independent.

The Grid Solution

.holy-grail {
  display: grid;
  min-height: 100dvh;
  grid-template-columns: 14rem 1fr 16rem;
  grid-template-rows: auto 1fr auto;
  grid-template-areas:
    "header header header"
    "nav    main   aside"
    "footer footer footer";
}

.hg-header {
  grid-area: header;
}
.hg-nav {
  grid-area: nav;
}
.hg-main {
  grid-area: main;
}
.hg-aside {
  grid-area: aside;
}
.hg-footer {
  grid-area: footer;
}

That's the entire layout. Let's walk through why each requirement is met.

  • Full-width header and footer: their area names fill all three columns of their rows.
  • Fixed sidebars, fluid main: 14rem 1fr 16rem gives the sidebars fixed widths and hands the remaining space to the center.
  • Equal-height columns: grid items in the same row stretch to the row's height by default (align-items: stretch), so the nav, main, and aside always match.
  • Sticky footer: min-height: 100dvh makes the grid at least as tall as the viewport, and grid-template-rows: auto 1fr auto gives all the extra space to the middle row. The footer ends up at the bottom when content is short, and simply follows the content when it's long.
  • Source order: placement comes from grid-area names, not from the HTML order.

The grid-template-areas property is essentially an ASCII drawing of the layout. Anyone reading the stylesheet can see the structure at a glance, which makes it very maintainable.

Why 100dvh?

The dvh unit is the dynamic viewport height. On mobile browsers, the address bar expands and collapses as you scroll, which changes the visible height. The classic 100vh is based on the largest possible viewport, so on phones it can be taller than the visible area and push the footer off screen. 100dvh tracks the current visible height. Use 100svh (small viewport height) instead if you want a stable value that doesn't change during scrolling. All three units are supported in current major browsers; add min-height: 100vh before it as a fallback for older ones:

.holy-grail {
  min-height: 100vh;
  min-height: 100dvh;
}

Adding Some Polish

The bare layout works, but a few refinements make it feel finished:

.holy-grail {
  display: grid;
  min-height: 100vh;
  min-height: 100dvh;
  grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr) minmax(
      12rem,
      18rem
    );
  grid-template-rows: auto 1fr auto;
  grid-template-areas:
    "header header header"
    "nav    main   aside"
    "footer footer footer";
  column-gap: 2rem;
}

.hg-header,
.hg-footer {
  padding: 1rem 1.5rem;
  background: #0f172a;
  color: #f8fafc;
}

.hg-main {
  padding-block: 2rem;
}

.hg-main > * {
  max-width: 70ch;
}

.hg-nav,
.hg-aside {
  padding-block: 2rem;
}

.hg-nav {
  padding-inline-start: 1.5rem;
}

.hg-aside {
  padding-inline-end: 1.5rem;
}

Two details are worth highlighting:

  • minmax(0, 1fr) instead of plain 1fr for the main column. A 1fr track has an automatic minimum equal to its content's minimum width, so a long URL or a wide code block can force the main column to grow and push the sidebars off screen. minmax(0, 1fr) allows the column to shrink below its content size, letting the wide content scroll or wrap inside it instead.
  • max-width: 70ch on main content children keeps line lengths readable on wide monitors, even though the column itself is fluid.

Sticky Sidebars

On long pages, it's nice if the navigation stays visible as you scroll. Make the sidebar content sticky:

.hg-nav > ul,
.hg-aside > * {
  position: sticky;
  top: 1.5rem;
}

Because grid items stretch to the full row height, the sidebar element itself is as tall as the main content. That gives its sticky child plenty of room to travel. Applying position: sticky to the sidebar element itself wouldn't work, since it's already full height and has nowhere to move. Alternatively, set align-self: start on the sidebar and make the sidebar itself sticky.

If the navigation is longer than the viewport, add max-height: calc(100dvh - 3rem) and overflow-y: auto so it can scroll independently.

Making It Responsive

On a phone there's no room for three columns. Stack everything in a single column, with the order you think is most useful. A mobile-first approach looks like this:

.holy-grail {
  display: grid;
  min-height: 100vh;
  min-height: 100dvh;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto auto 1fr auto auto;
  grid-template-areas:
    "header"
    "nav"
    "main"
    "aside"
    "footer";
}

@media (min-width: 48rem) {
  .holy-grail {
    grid-template-columns: 14rem minmax(0, 1fr);
    grid-template-rows: auto 1fr auto auto;
    grid-template-areas:
      "header header"
      "nav    main"
      "nav    aside"
      "footer footer";
    column-gap: 2rem;
  }
}

@media (min-width: 72rem) {
  .holy-grail {
    grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr) minmax(
        12rem,
        18rem
      );
    grid-template-rows: auto 1fr auto;
    grid-template-areas:
      "header header header"
      "nav    main   aside"
      "footer footer footer";
  }
}

At each breakpoint, only the grid template changes. The items keep their grid-area names, so there's nothing else to update. On tablets, the right sidebar drops below the main content while the navigation stays on the left, which is a nice intermediate layout that the float-based versions could never achieve easily.

Keep in mind that a visual order that differs from source order can confuse keyboard users. Here, nav appearing before main on mobile is fine because it's a common pattern, and there's a skip link (below) to jump straight to content.

Collapsing the Nav on Mobile

A long navigation list at the top of every mobile page is tedious. Wrapping it in a details element gives you a collapsible menu with zero JavaScript:

<nav class="hg-nav" aria-label="Documentation">
  <details class="hg-nav__toggle" open>
    <summary>Browse docs</summary>
    <ul>
      <li><a href="/docs/selectors">Selectors</a></li>
      <li><a href="/docs/cascade">Cascade</a></li>
    </ul>
  </details>
</nav>
@media (min-width: 48rem) {
  .hg-nav__toggle > summary {
    display: none;
  }
}

On desktop, the summary is hidden and the list is always visible because the details is open. On mobile, users can collapse it. Note that a hidden summary doesn't force a closed details open, so either keep the open attribute or add a line of script to open it on large screens.

Adding a Skip Link

Since the navigation comes before the content on small screens, give keyboard users a shortcut:

<a class="skip-link" href="#content">Skip to content</a>
.skip-link {
  position: absolute;
  left: 1rem;
  top: -3rem;
  padding: 0.5rem 1rem;
  background: #ffffff;
  color: #0f172a;
  z-index: 100;
}

.skip-link:focus {
  top: 1rem;
}

Place it as the first element inside body. It's hidden until focused, then slides into view.

Using Subgrid for Aligned Content

If the header or footer contains content that should line up with the columns below, such as a logo above the nav and a search bar above the main content, subgrid lets them inherit the parent grid's column tracks:

.hg-header {
  grid-area: header;
  display: grid;
  grid-template-columns: subgrid;
  align-items: center;
}

.hg-header .logo {
  grid-column: 1;
}

.hg-header .search {
  grid-column: 2;
}

Now the search bar starts exactly where the main column starts, and it stays aligned if you change the sidebar width. Subgrid is supported in all current major browsers. Note that a subgrid inherits the parent's column-gap as well.

The Flexbox Alternative

You can build the Holy Grail with Flexbox too. It needs an extra wrapper around the three columns:

<div class="hg-flex">
  <header>...</header>
  <div class="hg-flex__body">
    <main>...</main>
    <nav>...</nav>
    <aside>...</aside>
  </div>
  <footer>...</footer>
</div>
.hg-flex {
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}

.hg-flex__body {
  display: flex;
  flex: 1;
  gap: 2rem;
}

.hg-flex__body > main {
  flex: 1;
  min-width: 0;
}

.hg-flex__body > nav {
  flex: 0 0 14rem;
  order: -1;
}

.hg-flex__body > aside {
  flex: 0 0 16rem;
}

It works, and equal heights come for free from align-items: stretch. But notice the differences: an extra wrapper element, order: -1 to move the nav visually, and min-width: 0 to fix the same overflow issue we solved with minmax(0, 1fr). Responsive changes need several properties per breakpoint rather than one template. For page-level layout with rows and columns, Grid is the better tool. Flexbox shines for the one-dimensional pieces inside the layout, like the header's navigation links.

Common Pitfalls

  • Using 1fr without a minimum. Wide content will blow out the main column. Use minmax(0, 1fr).
  • 100vh on mobile. It can overshoot the visible area. Use 100dvh or 100svh with a 100vh fallback.
  • Forgetting that area names must form rectangles. An L-shaped area in grid-template-areas makes the whole declaration invalid, and the layout silently falls back to auto-placement.
  • Mismatched row counts. When you change grid-template-areas, update grid-template-rows to match the new number of rows.
  • Reordering too aggressively. Large differences between visual and source order hurt keyboard and screen reader users.

Conclusion

The Holy Grail layout was once a rite of passage in CSS. Today, grid-template-areas lets you draw it directly: header, three columns, and footer, with equal-height columns, a fluid center, a sticky footer via min-height: 100dvh and auto 1fr auto rows, and main content first in the source.

Make it responsive by swapping templates at breakpoints, use minmax(0, 1fr) to keep wide content from breaking it, add sticky sidebars and a skip link for comfort, and reach for subgrid when header content should line up with the columns below. The quest is over; the grail fits in a dozen lines.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading