Table of contents
Skip table of contentsIf you want to be kept up to date with new articles, CSS resources and tools, join our newsletter.
Every page has one main scroll container that scrolls the page itself: the root scroll container, or root scroller for short. Because it scrolls the entire page, the browser gives the root scroller a bunch of features that regular scroll containers don't get, and because they work automatically, most developers aren't aware of them.
It's easy to accidentally stop using the root scroller, and hard to notice when you have. Your 'page' still scrolls (visually at least) and you don't get any errors, but your site misses out on some features and that can make the user experience worse.
In this article we'll go over what the root scroller is, how it's different from other scroll containers, and how to fix it when you've accidentally opted out of it.
What are scroll containers?
A scroll container is any element that lets you scroll through content that doesn't fit inside it (which sometimes is referred to as overflowing content).
An element becomes a scroll container when you give it an overflow value of auto, scroll or hidden and fixed (explicit height or width) or constrained (like max-width, grid areas or flex constraints) dimensions. Overflow set to auto and scroll means users can scroll it with the mouse (and keyboard, when focused). With hidden they can't, but it's still a scroll container, and you can still scroll it programmatically.
When a scroll container sits inside the page or inside another scroll container, it's called a nested scroll container.
Try the different overflow values below to see how they affect the scroll container behavior.
This box has a fixed height of 150px, so this text overflows it.
Change the overflow value above and see what happens to the overflowing content.
With hidden you can't scroll it with your mouse or keyboard, but it's still a scroll container. Click the "scroll programmatically" button to see it scroll.
This is the last line, overflowing the container.
What is the root scroller?
When your content is taller than the viewport, the entire viewport becomes a scroll container. The specs call this the viewport's scrolling box, but browser engineers refer to it as the root scroller, and that's the name we'll use here.
The browser knows what the root scroller is, and document.scrollingElement returns the element that represents it:
document.scrollingElement; // <html> in standards modeIn standards mode this is the <html> element and in quirks mode it's <body>. That never changes for a given document, so document.scrollingElement is mostly just a convenient way to access the root scroller without having to check both elements or parsing the doctype.
Viewports and the root scroller
When dealing with the html and body elements, the browser sometimes applies styles and overflow behavior differently than it would for other elements: styling sometimes gets propagated to the viewport rather than being set on the element itself. This propagation was introduced to ensure that the viewport reflects the intended styles even when the html and body elements do not fill the entire viewport.
One that you might have encountered before is that adding a background to body will add it to the entire viewport even if body doesn't fill the full height. The background is "pushed back" to the root scroller.
That is, unless you also have a background set on the html element, in which case that background is applied to the viewport, and the one you set on the body behaves like a normal background. Try it out for yourself below:
The same happens to other properties that affect layout and scrolling. That propagation behavior is not something you commonly need to care about, but being aware of it can save you a few head scratchers.
The overflow CSS property controls how overflow works, and so creates scroll containers. The value set on <html> doesn't apply to the element itself but is propagated to the viewport. If <html> has the default value overflow: visible, the overflow value of <body> is propagated instead. We'll need this rule later on.
What the root scroller gets that nested scroll containers don't
There is nothing wrong with using nested scroll containers. Browsers treat them as independent elements with their own scrolling context, and a user can scroll them without affecting the rest of the page.
Sometimes though, you accidentally create a nested scroll container that encompasses the entire viewport. When you do that, a mouse wheel or touch scroll will scroll the nested container. When that happens, you lose special behavior given to the root scroller:
- Scroll restoration. When someone navigates back to your page, browsers restore the scroll position of the root scroller. Support for restoring nested scroll containers is inconsistent, so users often end up back at the top instead of where they were.
- Keyboard scrolling. Space, Page Down and the arrow keys scroll the root scroller by default. A nested scroll container needs to be clicked or focused into first.
window.scrollYand scroll events onwindowonly report the root scroller. Methods likewindow.scrollTo(),window.scrollBy()only apply to the root scroller. So anything that relies on these for getting and setting the scroll position will not work correctly with nested scroll containers.- Printing and screenshots. Printing uses the height of the root scroller. A page locked to the viewport height prints as a single page and cuts off the rest. Full-page screenshots have the same problem.
On mobile specifically
Though scroll-restoration is annoying everywhere, mobile browsers include more scroll-specific functionality that depends on the root scroller, and it's where things will feel more 'broken' as users interact with your website.
- Mobile browser UI. On phones the address bar and toolbar collapse when the root scroller scrolls. With a nested scroll container they stay visible, so your users have less screen space. (Mobile browser UI has a few more quirks, which we covered in using safe-area-inset to build mobile safe layouts.)
- Tap to scroll to top. On iOS, tapping the status bar scrolls the root scroller to the top. It does nothing for nested scroll containers.
- Pull to refresh and overscroll. These also only work on the root scroller.
The example below simulates these behaviors, toggle between the root and nested scrollers and see the difference when doing those actions above: scrolling to move the browser UI out of the way, clicking the top bar to scroll back to the top, and pulling down to refresh the page. When scrolled, reloading the page with the reload button will also restore the scroll position for the root scroller, but not for the nested scroller.
How you accidentally opt out
The most common version is adding an app shell or 'wrapper' like this:
html,
body {
height: 100%;
overflow: hidden;
}
.page-wrapper {
height: 100vh;
overflow: auto;
}When you look at your page it still looks the same, but you can see that the dashed border i added around body is fully visible at the bottom, while you would expect it to be pushed down by the content.
The content still scrolls, but it's .page-wrapper that is scrolling instead of the viewport. This is usually done on purpose, for a fixed header and sidebar with a scrolling content area, a sticky footer layout, or to prevent the page from scrolling behind a modal. Sometimes a framework sets it up for you.
The result is a root scroller that doesn't scroll, since that responsibility has been taken over by the nested scroll container.
Accidentally opting out with overflow propagation
Another common way to accidentally opt out of the root scroller is by setting that overflow-x property on both the html and body elements to prevent horizontal overflows, along with height: 100% to make the page at least as tall as the viewport and keep footers are where you want them. Variants of this can be found in many CSS boilerplates and frameworks.
html,
body {
height: 100%;
overflow-x: hidden;
}Because of the propagation rule, the overflow of <html> goes to the viewport. Since <html> is no longer set to overflow:visible, the overflow of <body> stays on <body>. And when one axis is hidden, the other axis computes to auto. So <body> becomes a scroll container.
Now just the overflow-x: hidden declaration on <body> is not a problem. <body> still grows with its content because of its natural block layout and doesn't become a scroll container.
That is, until you give it a fixed height with height: 100%. Now <body> is only as tall as the viewport and <body> scrolls instead of the root scroller. Both declarations are common on their own, so it's easy to end up with the combination without realising it.
Try the example below to see how the overflow is both propagated to the viewport, and causes the default values to change. See if you can guess when you've opted out of the root scroller, then scroll to see if you were right.
| overflow-x | overflow-y | |
|---|---|---|
| html | … | |
| body | … | |
| height | ||
html {
overflow-x: visible;
}
body {
overflow-x: visible;
}How to detect when you've opted out of the root scroller
Scroll down your page a bit and run this in the console:
window.scrollY;If this returns 0 while you've clearly scrolled, you've opted out of the root scroller and something else is scrolling. Here's that check side by side, for a root scroller and for a nested wrapper:
To find out which element is scrolling, listen for scroll events in the capture phase. Scroll events on elements don't bubble, but a capturing listener on the document does catch all of them.
Paste this into your console, then scroll:
document.addEventListener('scroll', (e) => console.log(e.target), { capture: true });If it logs #document, the root scroller is working. If it logs an element like div.page-wrapper, that's your nested scroll container.
The Elements panel in Polypane shows a "scroll" badge next to elements that are scroll containers, so you can spot them while going through the DOM tree.
Starting in Polypane 31, elements that are currently being scrolled will have their scroll badge highlighted so you know exactly which element is handling the scroll.
To list all vertical scroll containers on a page at once, run this in the console:
[...document.querySelectorAll('*')].filter((el) => {
const { overflowY } = getComputedStyle(el);
return ['auto', 'scroll', 'hidden'].includes(overflowY) && el.scrollHeight > el.clientHeight;
});Once you know what you're looking for, seeing it on a real phone is easy too. If the address bar doesn't collapse when you scroll, or tapping the status bar doesn't scroll you back up, the root scroller isn't being used.
Testing it in Polypane
Most of these issues only show up on touch devices, with a keyboard or when printing, so you won't see them with a mouse on a desktop viewport. But they'll still feel broken to users interacting with your site in those ways.
Test your pages at mobile sizes with touch events enabled using device emulation, try scrolling with only your keyboard, and run the capture listener above.
Another way it surfaces in Polypane
If you use Polypane, scroll syncing and full height screenshots can give it away too.
Polypane syncs the scroll position of all scroll containers, root and nested, across panes.
But if you accidentally create a nested scroll container at a specific breakpoint, for example by setting overflow inside a media query, the page is scrolled by the root scroller in some panes and by the newly created nested scroll container in others. Those aren't the same scroll container, so their scroll positions can't be synced.
If scrolling stays in sync between your desktop panes but your mobile pane doesn't follow along (or the other way around), check which element is scrolling in that pane using the methods described above.
For full height screenshots, Polypane takes the height of the root scroller to determine the total height of the page. If that's just a single viewport, then the resulting full height screenshot will be too.
Keeping the root scroller in common layouts
You usually end up with a nested scroll container while building a specific layout. Each of these layouts can be built while keeping the root scroller, so let's go through the common ones.
Make the page fill at least the full screen
For example so the footer sits at the bottom of the screen on short pages. A common way to do that is to make a wrapper exactly as tall as the viewport and let the content scroll inside it:
html,
body {
height: 100%;
overflow: hidden;
}
.page-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
}
main {
flex: 1;
overflow: auto;
}What you actually want is a page that is at least as tall as the screen, so use min-height instead of height and let the document grow with its content:
.page-wrapper {
display: flex;
flex-direction: column;
min-height: 100svh;
}
main {
flex: 1;
}You also don't really need the overflow: hidden on html and body either. Using svh or dvh instead of vh also avoids the mobile issue where 100vh is taller than the visible area. We wrote more about the small viewport units when we added them to Polypane's device emulation.
We can go a bit further too, since you probably don't even need a dedicated wrapper element for the page at all. Remove the wrapper entirely and apply the min-height and flex properties directly to the body and main elements:
body {
min-height: 100svh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}The body can be a wrapper just as much as a dedicated .page-wrapper element, simplifying your layout and keeping the root scroller intact by not restricting the height of the body element.
Have a header and sidebar that stay in place
This is the app shell layout: a header at the top, a sidebar on the side and only the content area scrolling. It's often built as a grid that's exactly as tall as the viewport, with the content area as a scroll container. This fits a nice mental model of the viewport as a fixed frame. The web isn't a fixed frame though. The viewport is just that, a view into a part of the document.
You can keep the root scroller intact by using position: sticky and using a min-height on the sidebar to get the same layout. The page scrolls normally and the header and sidebar stick to the viewport:
.app {
display: grid;
grid-template-columns: 100px 1fr;
grid-template-rows: auto 1fr;
height:100vh;
}
.header {
grid-column: 1 / -1;
}
.sidebar {
grid-row: 2 / -1;
}
.content {
overflow: auto;
}The sidebar can still scroll on its own if it's long (becoming its own scroll container). That's fine because it's a secondary scroll container, not the page itself. Content inside the main area will scroll with the page as expected.
If your sticky elements don't stick, check all the ways position: sticky can fail. For grid layouts, it's very easy to create that unexpected scroll container (making sticky elements not stick as intended) or to have the element not able to get stuck, because it stretches the full grid area.
Get rid of a horizontal scrollbar
Something on the page is wider than the viewport and causes a horizontal scrollbar, so you hide it:
html,
body {
height: 100%;
overflow-x: hidden;
}As we saw earlier, when <body> also has a fixed height, this turns it into the element that scrolls the page because setting overflow: hidden in one direction automatically changes it from visible to auto in the other direction.
A quick solution is to use clip instead:
html,
body {
height: 100%;
overflow-x: clip;
}clip cuts off overflowing content like hidden does, but it doesn't create a scroll container. The overflow of the other direction is still set to visible, and its contents will just overflow outside the body's height.
| overflow-x | overflow-y | |
|---|---|---|
| body | … |
html,
body {
height: 100%;
overflow-x: hidden;
}Quick fix vs. proper fix
This is a quick fix, but it isn't a good fix, since it hides the symptom (the bar is still overflowing).
Finding and fixing the element that causes the overflow is the better solution, and our article on strategies for dealing with horizontal overflows shows you how.
Polypane also detects horizontal overflow for you and highlights the element that causes it.
Stop the page scrolling behind a modal
When a modal is open, scrolling inside it shouldn't scroll the page behind it. Some sites solve this by keeping the page in a wrapper with its own scroll container permanently, so they can lock the wrapper while the modal is open.
You only need to lock the page while the modal is open. With :has() you can do that in CSS:
html {
scrollbar-gutter: stable;
&:has(dialog[open]) {
overflow: hidden;
}
}Hiding the scrollbar can make the page shift sideways, so we add scrollbar-gutter: stable to html to keep the space reserved.
Toggle the lock off and try scrolling behind an open dialog — then turn it back on:
/* no lock — the page behind the dialog can still scroll */Nested scroll containers are fine for specific use cases
Though most of this article is spent explaining how to avoid creating unwanted scroll containers, there's plenty of situations where you'll want to use them (like the scrollable sidebar in the example above, or a chat window).
What you want to avoid is accidentally creating a scroll container that covers your whole viewport, but there are plenty of other places where nested scroll containers are perfectly fine.
If you want to understand how scroll containers fit in with the rest of CSS layout, read understanding the fundamentals of CSS layout.
Use the root scroller for the main page
I hope this article has helped you understand why using the root scroller for the main scrolling area of your page is important and beneficial for your visitors, and helped you get a better grasp of managing scroll behavior in your own projects.
Check out some of the tools in Polypane to help you check and manage scroll behavior effectively:
- The code snippets above executed in our console panel make it really easy to catch differences introduced by media breakpoints, and help you make more resilient websites.
- With the device emulation in Polypane, you can see how your page behaves across different screen sizes and orientations, ensuring that the root scroller remains consistent and effective.
- The elements panel in Polypane allows you to inspect and manipulate the DOM, making it easier to understand how scroll containers are applied and how they affect your layout.
By combining these tools, you can confidently manage scroll behavior and create more robust and user-friendly web experiences.


