Skip to content
Chapter 20Lesson 8

Overflow and scroll containers

How CSS overflow and Tailwind's scroll utilities decide who owns a scroll when content stops fitting its box.

A modal is open: a long form taller than the window, so its body scrolls. You flick to the bottom, hit the end, and keep dragging, and the page behind the modal starts scrolling. On an iPhone it’s worse: the same flick fires the browser’s pull-to-refresh, and the whole screen lurches down with a spinner.

The same trouble shows up elsewhere. You give a dashboard a tall sidebar of nav links, mean only the sidebar to scroll, and instead the whole page scrolls, dragging your top bar out of view. You add a sticky header to that sidebar, and it refuses to stick, the unanswered half of the rule from the position and inset lesson: sticky needs a scrollable ancestor, but which one?

Until now every lesson sized and placed boxes whose content fit inside them. This is the lesson where content stops fitting, and the moment it does you have two questions to answer: who owns this scroll, and what happens when it reaches the end? One idea answers both, and explains every bug above.

A scroll container is a box that clips whatever overflows it and lets the user scroll inside that box to see the rest. The page itself is one; an inner element becomes one when you give it overflow plus a height its content can exceed.

What overflow controls: clipping and scroll containers

Section titled “What overflow controls: clipping and scroll containers”

overflow quietly does two jobs at once, and missing the second is what makes scroll bugs baffling. When content is bigger than its box, the property decides:

  1. Does it clip? Is the overflowing part cut off at the box’s edge, or does it spill out and stay visible?
  2. Does it create a scroll container? Does the box become something the user can scroll inside?

Ask both questions of each of the five values. The table answers them:

Value
Clips?
Scroll container?
Reach for it when…
visible
No
No
The default. Content spills past the box and stays visible. Every box you've built so far has been visible.
hidden
Yes
Yes
You want to cut off overflow and also want a scroll container (e.g. to give a sticky child something to stick to). The scroll-container part is the surprise.
clip
Yes
No
You want pure clipping with zero side effects. The clean clipper.
auto
Yes
Yes
A region is meant to scroll. Scrollbar appears only when needed. The default you reach for.
scroll
Yes
Yes
Almost never in 2026. Scrollbars show even when content fits.

The load-bearing column is the second one. Three values, hidden, auto, and scroll, turn the box into a scroll container; visible and clip do not. That split is the hinge of the lesson. A few facts the table can’t fit:

visible is the default you’ve used without typing it. Content that’s too big spills past the edge and stays visible, which is the only reason an overhanging badge can poke outside its card. No clipping, no scrollbar, no container.

hidden clips, and silently creates a scroll container too. That side effect is the trap: you reach for hidden to chop off overflow and get a scroll container you never asked for, one that can host a sticky child or swallow your overhanging badge.

clip looks identical to hidden in the preview but creates no scroll container. It can’t be scrolled, even by JavaScript, so it won’t become an accidental sticky ancestor. When you only want to cut off overflow, clip is the side-effect-free choice.

auto creates a scroll container and shows a scrollbar only when content overflows: a short list shows no bar, a long one does. This is the default for any region meant to scroll, like a chat panel, sidebar, or modal body.

scroll also creates a scroll container but shows scrollbars always, even when nothing overflows. In 2026 that job belongs to scrollbar-gutter, later in this lesson. Recognize scroll, but reach for auto.

The Tailwind utilities map one-to-one: overflow-visible, overflow-hidden, overflow-clip, overflow-auto, overflow-scroll.

To scroll one axis and hold the other firm, CSS splits the property into overflow-x (horizontal) and overflow-y (vertical), and Tailwind mirrors it across all five values: overflow-x-auto, overflow-y-hidden, and so on. Two pairings cover almost everything:

  • overflow-x-auto overflow-y-hidden is a horizontal scroller: a row of cards that scrolls sideways but never down. This is the carousel base you’ll build at the end.
  • overflow-y-auto overflow-x-hidden is a vertical panel: a sidebar or chat log that scrolls down but never sideways. This is the dashboard pattern you’ll build as the capstone.

One spec rule surprises everyone: you cannot set one axis to visible and the other to a scrolling value. Write overflow-x-hidden overflow-y-visible and the browser silently promotes the visible axis to auto, because a box can’t clip one axis while letting content spill freely on the other. So that pairing does not behave as written; the y axis becomes scrollable regardless. Pick hidden, clip, or auto for both axes.

One naming note: overflow-x and overflow-y map to the physical screen axes and do not flip under direction: rtl, unlike the logical utilities (ps-*, inset-s-*) this chapter favors. That’s deliberate, because scrolling is physical. There is no overflow-inline.

Before we make this tangible, lock in the distinction the rest of the lesson leans on. Sort the five values by the one question that matters: does each create a scroll container?

Sort each overflow value by whether it turns the box into a scroll container. Drag each item into the bucket it belongs to, then press Check.

Creates a scroll container The box can be scrolled
Doesn't create one No scrolling, ever
visible
hidden
clip
auto
scroll

hidden and clip are the two that trip people up: identical on screen, but only hidden makes a scroll container. That single difference is why hidden can host a sticky header and clip cannot.

A definition only gets you so far; the difference between clipping and scrolling is something you have to watch change on a single box. Below is one fixed-size bordered box holding more text than fits. The dropdown swaps its overflow through all five values, so try each and watch the overflowing text.

The pattern shows up fast. visible spills the text past the border, hidden and clip cut it off at the edge with no way to reach the rest, and auto and scroll let you scroll down to it. What your eyes can’t catch is the readout: hidden and clip look pixel-for-pixel identical, yet one made a scroll container and the other didn’t. As you switch values, watch the “scroll container?” chip flip between yes and no even when the picture holds still.

One box, more text than fits. Switch the overflow value and watch the text spill, clip, or scroll. hidden and clip look identical; the readout shows which one made a scroll container.

Toggle between hidden and clip: the box doesn’t move a pixel, but the readout flips. That is the heart of the topic. The most consequential thing overflow does is invisible in the preview, and the next sections turn that invisible difference into working layouts.

Stop scroll chaining with overscroll-behavior

Section titled “Stop scroll chaining with overscroll-behavior”

An inner box can become a scroll container. So what happens when the user scrolls past its edge: they flick the modal body to the bottom, and the content has nowhere left to go?

By default, the scroll chains : the browser hands the leftover momentum to the next scroll container out, and eventually to <body>. That’s why the page behind your modal scrolls. On a touch device the same chain triggers the platform’s overscroll gestures, the rubber-band bounce and the pull-to-refresh that reloads your whole app when the user only meant to scroll a panel.

One property decides what happens at the boundary: overscroll-behavior.

  • overscroll-contain stops the chain at this container’s edge. The leftover scroll stops here instead of leaking to the parent, while the platform’s bounce and glow still happen inside the container, so it feels native. This is the choice for every modal, drawer, dialog body, and inner-scrolling sidebar you build.
  • overscroll-none stops the chain and removes the bounce and glow. Reach for it on full-screen surfaces where even the rubber-band feels wrong, like a map or a canvas.
  • overscroll-auto is the default chaining behavior, what you get when you write nothing.
  • overscroll-y-contain and overscroll-x-contain stop chaining on one axis only.

Tailwind names them directly: overscroll-auto, overscroll-contain, overscroll-none, plus the overscroll-x-* and overscroll-y-* families.

Here is the bug and its one-class fix. Both tabs are the same modal body: the first has the scroll-chain bug, the second fixes it.

src/components/edit-invoice-dialog.tsx
<div className="max-h-[80dvh] overflow-y-auto p-6">
<InvoiceFields />
</div>

The chain leaks. This is a scroll container (overflow-y-auto), but with no overscroll control, scrolling past its bottom edge chains to the page behind it and fires pull-to-refresh on iOS.

Reach for overscroll-contain the moment you type overflow-y-auto on a modal body or a drawer.

One limit to know: overscroll-contain stops the chain but does not fully lock the page, and locking the background completely is harder (iOS ignores overflow: hidden on <body>), so a later project chapter builds a useLockBodyScroll hook for that job.

This resolves the debt from the position lesson. There you learned that a sticky element acts like relative until you scroll it to its offset, then pins like fixed within its parent, but the “scrollable ancestor” it pins inside was left undefined. That ancestor is a scroll container. sticky watches its nearest scroll container and pins when it would otherwise scroll out of view. With no scroll container, there’s nothing to stick to.

That combination unlocks one of the most common dashboard patterns: a sidebar that scrolls inside itself, with a header pinned to its top.

<aside className="h-dvh overflow-y-auto overscroll-contain">
<h2 className="sticky top-0 bg-white py-3">Navigation</h2>
{/* a long list of nav links… */}
</aside>

Three classes do the work. h-dvh (from the sizing lesson) fixes the sidebar at the full dynamic viewport height, so its content can exceed it. overflow-y-auto does double duty: it lets the sidebar scroll internally, and it’s the scroll container the sticky top-0 header pins to. Drop it and the header has no scroll container of its own, so it falls back to the page scroll and slides away with everything else. Drop sticky and the header just scrolls off the top. They only work as a pair. overscroll-contain keeps a flick at the sidebar’s end from scrolling the whole app.

Scrolling is hard to freeze into prose, so step through the sequence below to see all three at once: the header pinning, the list sliding under it, and the scroll staying inside the sidebar.

Acme Invoicing
Invoices

Top of the sidebar’s scroll. The “Navigation” header sits at the top, the link list flows below it, and the page content on the right holds steady.

Acme Invoicing
Invoices

Scroll the sidebar down. The header is now pinned to the top edge, earlier links sliding up underneath it as later links appear. The main content on the right hasn’t moved.

Acme Invoicing
Invoices

With overscroll-contain, flicking past the bottom stops here instead of chaining out to the page. The top bar and main content stay put.

Here’s a small bug worth fixing. You have a list that’s sometimes short, sometimes long, like search results, a filtered table, or an expandable panel. Short, there’s no scrollbar; long, a scrollbar appears and takes horizontal space from the content, jolting everything a few pixels left. Type into a search box and the results jump sideways as the bar pops in; filter back down and they jump back. On classic non-overlay scrollbars, Windows especially, it’s worse.

The fix is one property: scrollbar-gutter. It controls whether the browser reserves the scrollbar gutter , the strip a scrollbar lives in, even when no scrollbar is showing.

  • scrollbar-gutter-stable reserves the gutter always, scrollbar or not, so content never shifts when the bar appears or disappears. Reach for it on any container that conditionally overflows on interaction: a search-results list, a modal body, an expandable panel.
  • scrollbar-gutter-auto is the default. The gutter exists only when the scrollbar does, which is what causes the jolt.
  • scrollbar-gutter-both reserves a matching gutter on both sides for symmetry. It’s rare; reach for it only when a single-side gutter actually looks lopsided.

Every app picks one of two scroll models, whether you choose it or not. The question is which element is the page’s primary scroll container: <body>, or an inner element?

Page scroll means <body> is the scroll container, so the whole page scrolls as one document. It’s the 2026 default because the browser does so much for free when <body> scrolls: scroll restoration, back and forward landing where you were, anchor links (#section) jumping correctly, and deep-linking to the right spot. You write nothing. Reach for it on content sites, marketing pages, and most CRUD pages.

App-shell scroll means an inner <main> scrolls while the chrome around it, the top bar and sidebar, stays fixed. This is the dashboard look, with a persistent header and nav that never scroll away. The cost: once <body> stops scrolling, you own scroll restoration yourself. Back and forward no longer restore position, and native anchor and deep-link behavior can break. It’s a deliberate trade, not a default.

Walk the decision below the way you’d reason about a real screen.

Page scroll or app-shell scroll?

That min-h-0 is the third appearance of a floor you’ve already met twice. In the flexbox lesson, a flex item wouldn’t shrink below its content’s intrinsic width without min-w-0; in the grid lesson, the same floor showed up as minmax(0, …). Here it’s the vertical version: a flex or grid child won’t shrink below its content’s intrinsic height without min-h-0. So in an app-shell vertical flex column, a top bar above a flex-1 content row, that row refuses to shrink to the viewport and never becomes scrollable no matter how much overflow-y-auto you add. min-h-0 on the scrolling child fixes it: the same floor as min-w-0 and minmax(0, …), now on the vertical axis.

CSS scroll snap: carousels without a library

Section titled “CSS scroll snap: carousels without a library”

Carousels, image galleries, and swipe decks used to mean a JavaScript library that measured widths, tracked drag velocity, and snapped to the nearest slide. In 2026, a handful of CSS utilities do the same job, with no library and no JavaScript.

The feature is scroll snapping , and it splits across two elements. On the scroll container, you turn snapping on and pick the axis and strictness:

  • snap-x snaps on the horizontal axis (snap-y for vertical).
  • snap-mandatory makes the scroll always settle on a snap point, so it can’t stop between slides.
  • snap-proximity settles only when the scroll ends near a point, and otherwise stops freely. It’s gentler.

On each child, you pick which edge aligns to the container:

  • snap-start aligns the child’s start edge (the usual choice for a card row).
  • snap-center centers the child (good for a single-image-at-a-time gallery).
  • snap-end aligns the child’s end edge.

For a card carousel, that’s flex gap-4 overflow-x-auto snap-x snap-mandatory on the row and snap-start on each card. One companion fix keeps it from silently breaking, and you already know it in another form: wide cards dropped into a flex row don’t overflow, they shrink to fit the visible width, leaving nothing to scroll. That’s the flexbox default from the min-w-0 story, now in a horizontal-scroll context. The fix comes from the same family: shrink-0 on each card (or a fixed width), so the cards keep their real width and overflow the row instead of collapsing into it.

So the production-grade shape is:

<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory
scroll-px-4 pb-4">
{products.map((product) => (
<article
key={product.id}
className="w-64 shrink-0 snap-start rounded-xl border p-4"
>
{/* card contents */}
</article>
))}
</div>

The carousel below is that exact pattern, live. Scroll it sideways and feel each card snap into place.

Scroll sideways

1
Add-on
Starter seat $12/mo
2
Add-on
Team seat $29/mo
3
Add-on
Usage credits $0.004/req
4
Add-on
Priority support $99/mo
5
Add-on
Audit log $19/mo
6
Add-on
SSO add-on $49/mo
A real carousel, no library: snap-x snap-mandatory on the row and shrink-0 snap-start on each card. Open devtools and inspect the row, every utility doing the work is right there in the styles.

The scroll-px-4 in that code (Tailwind’s scroll-padding-inline) insets each snap point by 1rem, so a snapped card sits with breathing room instead of jammed against the edge. In a real layout it also keeps a snapped item from hiding under a sticky header.

Now you’ll assemble the lesson into the layout you’ll reach for constantly: the dashboard app shell. It’s a fixed-height surface where the sidebar scrolls on its own, its header pinned to the top, beside a main region, while the page stays put.

Four classes, all from this lesson and the last, make it work. overflow-y-auto turns the <aside> into a scroll container; overscroll-contain stops that scroll from leaking to the page; sticky top-0 pins the header inside it; and min-h-0 on the parent flex row lets the sidebar shrink enough to scroll instead of stretching to fit all its links. Drop overflow-y-auto and the header scrolls away instead of pinning; drop min-h-0 and the sidebar never scrolls at all. Match the target.

Make the sidebar scroll on its own with its 'Navigation' header pinned to the top — the page itself shouldn't scroll. The layout and content are already in place; you add the scroll-container classes. The sidebar's header should stay put while its links scroll under it, and flicking the sidebar to its end shouldn't scroll the whole shell. Match the target.

Target
Your output LIVE

The <aside> and <main> tags aren’t decoration: they’re real landmarks, so a screen-reader user navigates straight to the sidebar’s scroll region. This shell, chrome that stays put around a sidebar that scrolls itself, is the layout you’ll rebuild on nearly every dashboard you ship.

The references worth keeping open while the scroll-container model settles in.