Sticky Footer With Flexbox or Grid: Pinning the Footer on Short Pages
A 404 page, an empty search result or a login screen has barely any content, and the footer ends up floating halfway up the viewport with a stripe of blank background below it. The requirement is precise: on a short page the footer should sit at the bottom edge of the viewport; on a long page it should come after the content and scroll away like anything else. This page solves exactly that with a single rule on each of two elements, in both flexbox and grid form, and handles the mobile viewport-height trap that breaks most copies of it. It sits in Flexbox Layout Patterns, inside the broader Mastering Container Queries & Responsive Layouts guide.
Why a layout rule and not positioning
Before flexbox, sticky footers relied on negative margins and a push element whose height had to match the footer's exactly, so a footer that grew a second line of links broke the page. Positioning is the other temptation, and it answers a different question: position: fixed keeps the footer on screen permanently, covering the last lines of every long page, and position: sticky makes it cling to the viewport while scrolling. Neither is "at the bottom when there is room, after the content when there is not".
The layout approach gets that behaviour for free because it describes the relationship instead of the position: the page is at least as tall as the viewport, and the main region absorbs whatever space is left over. Nothing depends on the footer's height, so the footer can wrap, grow or disappear and the rule still holds.
There is no accessibility cost. The DOM order stays header, main, footer, landmarks stay in place for screen-reader navigation, and nothing is taken out of flow, so zooming to 400% simply produces a long page with the footer at the end.
How the free space is shared
The mechanism has two parts, and the diagram shows both. The outer container — normally body — gets a min-height equal to the viewport, which creates free space whenever the content is shorter. Then one child, the main region, is told to take all of that free space.
When content is taller than the viewport, there is no free space to share, min-height stops mattering, and the footer lands immediately after the content. The rule is inert on long pages, which is exactly why it is safe to apply site-wide.
The complete implementation
The document below uses the flexbox form on body. The alternative grid rule is included as a comment block so the two can be compared line for line.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sticky footer</title>
<style>
/* Remove the default body margin, or min-height plus margin
overflows the viewport and adds a permanent scrollbar. */
html, body { margin: 0; }
body {
display: flex;
flex-direction: column;
/* Two declarations on purpose: browsers without dvh keep the first,
browsers with it use the second, which tracks the mobile toolbar. */
min-height: 100vh;
min-height: 100dvh;
font: 16px/1.6 system-ui, sans-serif;
}
/* The only element that grows. flex: 1 0 auto = grow into free space,
never shrink below content, start from content height. */
main {
flex: 1 0 auto;
padding: 1.5rem;
}
header, footer {
padding: 1rem 1.5rem;
background: #1e293b;
color: #f8fafc;
}
footer a { color: #bfdbfe; }
/* GRID ALTERNATIVE — replace the body rule above with:
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
min-height: 100dvh;
}
and delete the main flex rule. The 1fr row plays the role of flex-grow. */
</style>
</head>
<body>
<header>Site name</header>
<main>
<h1>No results</h1>
<p>Try a shorter search term.</p>
</main>
<footer>
<a href="/">Home</a> · <a href="/contact/">Contact</a>
</footer>
</body>
</html>
Two rules deserve a closer look. flex: 1 0 auto rather than the more common flex: 1 matters: flex: 1 sets the basis to zero and allows shrinking, so on a very long page main could be squeezed and its content overflow into the footer. A zero shrink factor and an auto basis keep the main region at least as tall as its content. And the duplicated min-height is the fallback pattern in its simplest form — an unknown unit invalidates only its own declaration, so older engines keep 100vh.
The key technique: min-height, never height
Everything depends on the outer container being at least the viewport height rather than exactly it. With height: 100vh the body is capped, long content overflows the body's box, and the footer, which is laid out inside the body, sits at the bottom of that capped box — right on top of the overflowing content. min-height removes the cap, so the body grows with its content and the footer always follows the last line.
The choice of viewport unit is the other half. On mobile Safari and Chrome, 100vh is the large viewport height — the size with the browser toolbar retracted. When the page first loads the toolbar is showing, so a 100vh body is taller than the visible area and the footer starts just below the fold.
100dvh is the natural fit for a sticky footer because it is always the visible height: the footer sits on the visible bottom edge whether the toolbar is shown or hidden. The one cost is that the body's minimum height changes as the toolbar animates, which on a short page nudges the footer by the toolbar's height. If that movement bothers you, 100svh pins the minimum to the smallest viewport and never changes, at the price of a small gap below the footer once the toolbar retracts. The full comparison of the three units lives in Container Query Units: cqi, cqb Explained, which also covers when container units beat viewport units entirely.
Variation: an app shell with a scrolling main region
Dashboards and web apps often want the opposite of a document footer: header and footer always visible, and only the middle region scrolling. The same grid template produces it with two changes — an exact height instead of min-height on the shell, and overflow: auto plus min-height: 0 on the middle row.
.app {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
height: 100dvh; /* exact, so the shell never scrolls */
}
.app > main {
min-height: 0; /* release the automatic minimum so 1fr can shrink */
overflow: auto; /* the scrollbar lives here, not on the page */
overscroll-behavior: contain;
}
/* A scrollable region must be reachable by keyboard. */
.app > main:focus-visible {
outline: 2px solid #2d5bff;
outline-offset: -2px;
}
Add tabindex="0" and an accessible name such as aria-label="Messages" to that <main> so keyboard users can scroll it; a scroll container with no focusable content is otherwise unreachable, which is an axe-core failure as well as a usability bug. The min-height: 0 line is the same automatic-minimum release covered in Preventing Flex and Grid Overflow; without it the 1fr row refuses to shrink below its content and the whole shell scrolls instead. When the middle region needs its own internal columns, the shell is a natural parent for the Holy Grail Layout With Grid.
When a framework adds a wrapper
The most common way this pattern breaks in production is not the CSS at all but an extra element. Single-page frameworks mount into a root <div id="app"> or <div id="__nuxt">, so body has exactly one child and the header, main and footer are its grandchildren. The flex or grid rule on body now lays out that one wrapper, which is only as tall as its content, and the footer floats again.
There are two fixes, and which one to use depends on how much of the chain you control. The first moves the whole rule down one level: give the wrapper min-height: 100dvh and the column layout, and leave body alone. The second passes the height through: make body a column flex container with the viewport minimum, make the wrapper flex: 1 0 auto and itself a column flex container, and then grow main inside it. The second version survives if the framework later adds a second top-level node, such as a portal container for toasts, because each level only claims the space its parent offers.
A related trap is display: contents. Some component libraries set it on wrappers so they do not interfere with layout, and it genuinely works here: an element with display: contents generates no box, so its children participate directly in the parent's flex or grid layout. The catch is that older engines dropped such elements from the accessibility tree, so reserve it for wrappers that carry no semantics — never for the <main> or <footer> landmark itself.
Finally, check for a height: 100% chain inherited from an older stylesheet. Percentage heights resolve against the parent's definite height, so html { height: 100% } plus body { height: 100% } reproduces the capped-height overlap bug described below even when the outer rule uses min-height. Delete those declarations when adopting this pattern; the viewport unit already provides the only height reference the layout needs.
Browser support
Column flexbox and grid-template-rows with 1fr are supported in every current browser; CSS Grid dates from Chrome 57, Firefox 52 and Safari 10.1. The dynamic, small and large viewport units arrived in Chrome and Edge 108, Firefox 101 and Safari 15.4. Because the 100vh declaration comes first, older engines fall back to it automatically — no @supports block is needed. If you want to scope the unit explicitly, @supports (height: 100dvh) is a valid guard.
FAQ
Why does min-height: 100vh leave a gap or scrollbar on mobile?
On mobile browsers 100vh is the height with the address bar retracted, which is taller than the visible area when the bar is showing. Use 100dvh, which tracks the bar as it appears and disappears, or 100svh for the smallest stable height, with 100vh declared first as a fallback.
Should the footer use position: fixed or position: sticky?
Neither, for this pattern. A fixed footer is always on screen and covers content; a sticky footer in the positioning sense sticks while scrolling. The layout technique here only pushes the footer down on short pages and lets it scroll normally on long ones.
Is the flexbox or the grid version better?
They produce the same result. Grid with grid-template-rows: auto 1fr auto states the three rows explicitly and tolerates extra wrapper elements less well; flexbox with flex-grow on the main element tolerates any number of other children. Pick the one that matches how many direct children the body has.
Why does my footer overlap content when the page is long?
Because something in the chain has a fixed height instead of a minimum height. With height: 100vh the container cannot grow past the viewport, so long content overflows it and the footer sits on top. Always use min-height on the outer container.
Related
- Flexbox Layout Patterns — the parent guide to flex-based page structure.
- Breakpoint-Free Rows With flex-wrap — the same free-space sharing, applied along the row axis.
- Holy Grail Layout With Grid — adding sidebars inside the main region.
- Scroll-Triggered Reveal Animations — animating content as it enters a scrolling main region.
Related articles
More pages in the same section.