# The root scroller and how not to lose it

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`](https://developer.mozilla.org/en-US/docs/Web/CSS/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.

overflowvisibleautoscrollhidden

ScrolledNot a scroll container

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:

```js
document.scrollingElement; // <html> in standards mode
```

In 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:

Add background to body

Add background to html

viewport background…

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`](https://developer.mozilla.org/en-US/docs/Web/CSS/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.scrollY` and scroll events on `window` only report the root scroller.** Methods like `window.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](https://polypane.live/blog/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.

Root scrollerNested scroll container

Reload page

Card 1

Card 2

Card 3

Card 4

Card 5

Card 6

Card 7

Card 8

Card 9

Card 10

Card 11

Card 12

Card 13

Card 14

Card 15

Card 16

Card 17

Card 18

Card 19

Card 20

## How you accidentally opt out

The most common version is adding an app shell or 'wrapper' like this:

```css
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.

scroll containerscroll to find out

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.

```css
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 | visiblehidden | … |
| body | visiblehidden | … |
| height | set to 100% on both |

scroll container…

propagated to viewport…

```
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:

```js
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:

Root scroller

0px

window.scrollY

Nested wrapper

0px

window.scrollY

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:

```js
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](/docs/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:

```js
[...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.

[

### Polypane has everything you need to build great sites

Learn about Polypane](/)

### 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](https://polypane.live/docs/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](/docs/synced-interactions/) and [full height screenshots](/docs/making-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:

```css
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:

```css
.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`](https://developer.mozilla.org/en-US/docs/Web/CSS/length#relative_length_units_based_on_viewport) 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](https://polypane.live/blog/polypane-26-accurate-device-emulation-with-safe-area-and-small-viewport-units/).

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:

```css
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:

Broken: fixed-height gridFixed: sticky layoutFixed, with scrollable sidebar

scroll event targetscroll to find out

```
.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](https://polypane.live/blog/getting-stuck-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:

```css
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:

```css
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 | hiddenclip | … |

scroll container…

```
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](https://polypane.live/blog/strategies-for-dealing-with-horizontal-overflows/) shows you how.

Polypane also [detects horizontal overflow](https://polypane.live/docs/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()`](https://polypane.live/blog/decoding-css-selectors-has-not-vs-not-has/) you can do that in CSS:

```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`](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter) to `html` to keep the space reserved.

Toggle the lock off and try scrolling behind an open dialog — then turn it back on:

Toggle scroll locking on the HTML

dialog open…

html overflow-y…

scrollY0px

```
/* 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](https://polypane.live/blog/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](/docs/console/) make it really easy to catch differences introduced by media breakpoints, and help you make more resilient websites.
*   With the [device emulation](/docs/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](/docs/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.

---

Polypane is the browser built for developers and designers — try it at https://polypane.live.
