Skip to content
Chapter 20Lesson 8

Overflow and scroll containers

How CSS overflow and scroll containers in Tailwind decide who owns a scroll and what happens when content stops fitting its box.

A modal is open on your screen: a long form, taller than the window, so its body scrolls. You flick down to read the bottom, hit the end, and keep dragging. The page behind the modal starts scrolling. On an iPhone it’s worse, because the same flick fires the browser’s pull-to-refresh, and the whole screen lurches down with a spinner. You didn’t ask for any of that. You were trying to scroll one box.

The same bug shows up in another form. You build a dashboard with a tall sidebar full of nav links. You only meant the sidebar to scroll, but instead the entire page scrolls, dragging your top bar out of view with it. Or you add a sticky header to that sidebar, and it refuses to stick.

Every one of these traces back to a single question you’ve never had to ask until now, because until now everything fit. The lessons up to this point sized and placed boxes on the assumption that the content was smaller than the box. This is the lesson where content stops fitting, and the moment it does, you have to answer two questions: who owns this scroll, and what happens when it reaches the end? By the time you finish here you’ll create a scrolling region on purpose, stop a scroll from leaking into the page, pin a header inside a scrolling panel, and build a swipeable carousel, all without the iOS bugs.

There’s one thread to pick up before we start. In the lesson on position and inset utilities, you learned that sticky “needs a scrollable ancestor,” but the question of what that ancestor actually is was left for later. This is where you answer it. A single concept, a box that owns its own scrolling, explains the modal bug, the sidebar bug, the sticky bug, and the carousel, which all looked unrelated a paragraph ago. That box has a name.

A scroll container is a box that clips whatever overflows it and lets the user scroll inside that box to see the rest. Hold onto that noun, because everything in this lesson builds on it.

The whole topic starts with one CSS property, overflow, and the key thing to understand about it is that it quietly does two jobs at once. Most explanations teach the first job and bury the second, which is exactly why scroll bugs feel so baffling. So we’ll lead with both.

When content is bigger than its box, overflow decides two things:

  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 this box become a thing the user can scroll inside?

There are five values, and the fast way to keep them straight is to ask those two questions of each one. Here they are, framed that way:

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.

Read that table by the second column, the one most people ignore. Three of the five values, hidden, auto, and scroll, turn the box into a scroll container. The other two, visible and clip, do not. That split is the hinge of the entire lesson, so let’s walk the five one at a time.

visible is the default, and you already know it well even though you’ve never typed it. Content that’s too big spills out past the box’s edge and stays fully visible. An overhanging badge that pokes outside its card (the pattern from the position lesson) only works because the card is visible. No clipping, no scrollbar, no scroll container.

hidden clips. The overflowing part is cut off at the box’s edge and you cannot reach it, and no scrollbar appears. This is the value people reach for when they just want to chop off overflow, and it’s also the one that catches them out, because of that second job: hidden silently creates a scroll container. That side effect stays invisible until it doesn’t. It’s what gives a sticky child something to stick to, which we’ll use on purpose later, and it’s what would shave off your overhanging badge if you put it on the wrong card. You reach for hidden to clip, and you get a scroll container you never asked for.

clip looks identical to hidden in the preview, with overflow cut off at the edge and nothing reachable, but under the hood it’s fundamentally different: clip does not create a scroll container. It can’t be scrolled, even by JavaScript; it’s pure visual clipping and nothing else. This is the 2026 choice when all you want is to chop off overflow without the side effects, since it won’t accidentally become a sticky ancestor and it won’t swallow a badge through programmatic scroll. If you’ve only ever used hidden to clip, clip is the value you’ve been missing.

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

scroll also creates a scroll container, but it shows scrollbars always, even when the content fits and there’s nothing to scroll, leaving a permanent empty track. It was useful once for preventing layout shifts, but in 2026 that job belongs to scrollbar-gutter, which comes up later this lesson, and on macOS’s overlay scrollbars scroll just looks heavy. Recognize it, but reach for auto instead.

The Tailwind utilities map one-to-one onto the CSS, with no surprises: overflow-visible, overflow-hidden, overflow-clip, overflow-auto, overflow-scroll.

So far overflow controls both directions at once. Real layouts often want to scroll on one axis and hold firm on the other. CSS splits the property into overflow-x (horizontal) and overflow-y (vertical), and Tailwind mirrors it: overflow-x-auto, overflow-y-auto, overflow-y-hidden, and so on across all five values.

Two pairings cover almost everything:

  • overflow-x-auto overflow-y-hidden is a horizontal scroller: a row of cards that scrolls sideways but never vertically. 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.

There’s one rule the spec enforces that surprises everyone the first time, so let’s state it plainly: you cannot set one axis to visible and the other to a scrolling value. If you write overflow-x-hidden overflow-y-visible, the browser silently promotes the visible axis to auto, because a box can’t clip on one axis while letting content spill freely on the other. The geometry doesn’t work. So overflow-x-hidden overflow-y-visible does not behave as written: the y axis becomes scrollable whether you wanted it to or not. The practical takeaway is to never mix visible with a clipping value across axes. Pick hidden, clip, or auto for both.

A quick word on naming, because there’s a genuine inconsistency in the toolkit here, and it’s better to have it pointed out than to trip over it. Everywhere else, this chapter pushed you toward logical properties, like ps-*, pe-*, and inset-s-*, which flip automatically in a right-to-left language. The overflow utilities don’t follow that pattern. overflow-x and overflow-y map to the physical viewport axes, horizontal and vertical, not the logical inline and block axes, and they don’t flip under direction: rtl. That’s by design, because scrolling is a physical, screen-oriented concern. So overflow-x and overflow-y are a deliberate physical exception in an otherwise logical-first toolkit. Don’t go hunting for overflow-inline utilities: there aren’t any, and you don’t want them.

Before we make this tangible, lock in the one distinction that the rest of the lesson leans on. Sort these five values by the question that actually matters: does each one 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

The two that catch people are hidden and clip. They look the same on screen, since both chop off overflow, but hidden lands in the creates a container column and clip does not. That single difference is why hidden can host a sticky header and clip cannot. If the only thing you carry out of this section is that hidden makes a scroll container and clip is the clean clipper, you’re set up for the rest.

Definitions only get you so far, because the difference between clipping and scrolling is something you have to see change on a single box. The playground below is one fixed-size bordered box with more lines of text than fit. The dropdown swaps its overflow value through all five. Try each one and watch what happens to the overflowing text.

You’ll notice the pattern quickly. visible spills the text out past the border; hidden and clip cut it off at the edge with no way to reach the rest; auto and scroll let you scroll down to it. But here’s what your eyes can’t see: hidden and clip look pixel-for-pixel identical in the preview, yet one created a scroll container and the other didn’t. That invisible difference is exactly what breaks sticky headers later, so the chip under the preview surfaces it for you. As you switch values, watch the “scroll container?” readout flip between yes and no even when the picture doesn’t change.

One box, more text than fits. Switch the overflow value and watch the text spill, get clipped, or become scrollable. hidden and clip look identical, but the readout shows only one of them made a scroll container.

Sit on hidden and clip for a second and toggle between just those two. The box doesn’t move a pixel, but the readout flips. This is the heart of the topic: the most consequential thing overflow does is invisible in the preview. The next sections turn that invisible difference into working layouts.

What happens at the edge: overscroll-behavior

Section titled “What happens at the edge: overscroll-behavior”

You now know an inner box can become a scroll container. The next question, the one nobody thinks to ask until a bug forces it, is what happens when the user scrolls past that container’s edge. They scroll the modal body to its very bottom, then keep flicking. The content has nowhere left to go. So where does the scroll go?

By default, it chains. Scroll chaining is the browser handing the leftover scroll momentum up to the next scroll container out: the modal’s parent, and eventually the page itself. That’s why the page behind your modal scrolls: the modal hit its limit, and the chain delivered the rest of your flick to <body>. On a touch device that same chain triggers the platform’s overscroll gestures, namely 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 controls this: overscroll-behavior. It decides what happens at the boundary.

  • overscroll-contain stops the chain at this container’s edge. The leftover scroll stops here instead of leaking to the parent. The platform’s own bounce and glow still happen inside this container, so it feels native, but they never reach the page. This is the choice for every modal, drawer, dialog body, and inner-scrolling sidebar you will ever build.
  • overscroll-none stops the chain and removes the bounce and glow entirely. Reach for it on full-screen app 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.
  • The per-axis forms overscroll-y-contain and overscroll-x-contain apply when only one axis should stop chaining.

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

Here is the bug made concrete, because you will ship it at least once, and the fix really is one class. The two tabs below 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.

That’s the whole fix. overscroll-contain belongs on the scrollable element of every overlay you build, so type it the moment you type overflow-y-auto on a modal body or a drawer.

One honest caveat, so you know where this tool’s limits are. overscroll-contain stops the chain, but it does not fully lock the page. If your goal is that the background can’t scroll at all while the modal is open, that’s a separate and genuinely harder problem. The obvious fix, overflow: hidden on <body>, works on desktop, but iOS touch scrolling ignores it, so the page still moves under your modal. The real solution pairs body locking with touch-event handling, wrapped into a reusable useLockBodyScroll hook. The project chapter builds exactly that hook, and you’ll see why iOS makes it hard. For now, the thing to hold onto is that stopping the chain and locking the page are two different jobs: stopping the chain is one class, locking the page is a hook, and you only need the first to kill the bug in the demo above.

Now we can return to the debt from the position lesson. Back there you learned that a sticky element behaves like relative until you scroll it to its offset, then pins like fixed within its parent’s bounds, but the “scrollable ancestor” it needs was left undefined. Here is that definition: the scrollable ancestor is a scroll container. sticky watches its nearest scroll container as it scrolls, and pins when it would otherwise scroll out of view. With no scroll container, there’s nothing to watch and nothing to stick to.

This unlocks one of the most common dashboard patterns you’ll build, and it combines this lesson with the last: a sidebar that scrolls inside itself, with a header pinned to its top. Here’s the shape:

<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 are doing the real work, and pulling them apart shows why the two lessons go together. h-dvh (from the sizing lesson) gives the sidebar a fixed height, the full dynamic viewport height, so its content can exceed it. overflow-y-auto turns it into a scroll container, which does two things at once: it lets the sidebar scroll internally, and it’s the thing the sticky top-0 header needs to stick to. Pull overflow-y-auto out and the header has no scroll container of its own, so it falls back to the page’s scroll, scrolls away with everything else, and the “sticky” looks broken. Pull sticky out and the header just scrolls up and off as you scroll the list. They only work together. The overscroll-contain is there too, so flicking the sidebar to its end doesn’t scroll the whole app.

Scrolling is motion, and motion is hard to freeze into prose. Step through the sequence below to see all three pieces working at once: the header pinning, the list sliding under it, and the scroll staying inside the sidebar while the page behind it doesn’t budge.

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’s own content sits steady to the right.

Acme Invoicing
Invoices

Scroll the sidebar down. The header is now pinned to the sidebar’s top edge, with earlier links sliding up underneath it while later links come into view. The main content on the right hasn’t moved.

Acme Invoicing
Invoices

Because the sidebar has overscroll-contain, flicking past its bottom stops right here, and the scroll never chains out to scroll the page. The top bar and main content stay exactly where they were.

Here’s a small bug that’s hard to unsee once you notice it. You have a list that’s sometimes short and sometimes long, like search results, a filtered table, or an expandable panel. When it’s short, there’s no scrollbar. When it’s long, a scrollbar appears, and on the way in it takes horizontal space from the content, so everything jolts a few pixels to the left. Type into a search box and watch the results jump sideways as the scrollbar pops in. Filter them back down and watch it jump back. It’s not subtle once your eye catches it, and on classic non-overlay scrollbars, Windows especially, it’s worse.

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

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

Here’s a decision an experienced developer makes on purpose and a beginner makes by accident: which of two scroll models the app uses. It’s rarely framed as a decision at all, which is how it ends up made on autopilot and quietly regretted later.

Every app you build picks one of two scroll models, whether you choose it consciously 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. The whole page scrolls as one document, with no inner scrolling region for the main content. This is the 2026 default, because of everything the browser does for you for free when <body> scrolls: back and forward restore your scroll position automatically, navigating away and back drops you where you were, anchor links (#section) jump correctly, and deep-linking lands at the right spot. You get all of that by doing nothing. Reach for it on content sites, marketing pages, and most CRUD app pages, anything document-shaped.

App-shell scroll means an inner <main> scrolls while the chrome around it, the top bar and the sidebar, stays fixed in place. This is the dashboard look: a persistent header and nav that never scroll away, with only the content region moving. It’s the right choice for dashboards and tool-style UIs where that fixed chrome is the point. But it has a real cost: the moment <body> stops scrolling, you’ve taken over scroll restoration yourself. Back and forward no longer restore position automatically, and you can break native anchor and deep-link behavior if you’re not careful. It’s a deliberate trade, not a default.

Walk the decision below the way you’d reason about a real screen. As always, the value isn’t the leaf at the bottom; it’s the order of the questions, which is how an experienced developer arrives at the answer in seconds.

Page scroll or app-shell scroll?

That min-h-0 in the app-shell leaf is not a throwaway. It’s the third appearance of a trap you’ve already met twice, and the connection across all three is worth drawing out. 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, …), keeping a track from refusing to shrink. Here it’s the vertical version: a flex or grid child won’t shrink below its content’s intrinsic height unless you give it min-h-0. So when your app shell is a vertical flex column, with a top bar above a flex-1 content row, the content row silently refuses to shrink to the viewport. Instead it grows to fit all its content, and never becomes scrollable no matter how much overflow-y-auto you throw at it. The fix is min-h-0 on that scrolling child: the same floor as min-w-0 and minmax(0, …), now on the block axis. Once you’ve seen it on all three axes, it stops being a mystery.

Snap scrolling: carousels without a library

Section titled “Snap scrolling: carousels without a library”

In this section the platform quietly retires an entire category of JavaScript library. Carousels, image galleries, and story-style swipers: for years these meant pulling in a dependency that measured widths, tracked drag velocity, and snapped to the nearest slide. In 2026, four 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 you can’t stop between slides.
  • snap-proximity settles on a snap point only when the scroll ends near one, and otherwise stops freely. It’s gentler.

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

  • snap-start aligns the child’s start edge to the container (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.

That’s the whole API for a card carousel: flex gap-4 overflow-x-auto snap-x snap-mandatory on the row, and snap-start on each card. But there’s one companion fix without which the carousel silently breaks, and it’s something you already know in a different form. Drop wide cards into that flex row and they don’t overflow; they shrink to fit, squashing into the visible width and leaving nothing to scroll. That’s flexbox doing its default job, since flex items shrink to fit their container. It’s the same behavior as the min-w-0 story from the flexbox lesson, now in the horizontal-scroll context. The fix is from the same family too: 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, rendered live. Scroll it sideways (or drag, on a trackpad) and feel it snap each card into place. Inspect it in DevTools: there’s no JavaScript behind the snapping, just the utilities you see in the code.

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. The snapping is pure CSS, snap-x snap-mandatory on the row and shrink-0 snap-start on each card, with no JavaScript behind it. 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) nudges each snap point inward by 1rem, so a snapped card sits with a little breathing room instead of jammed against the container’s edge. In a real layout, it also keeps a snapped item’s top from hiding under a sticky header. It’s one utility, and it’s the difference between a carousel that feels considered and one that feels glued to the wall.

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

Every piece you need is from this lesson and the last. The sidebar is a height-constrained scroll container (overflow-y-auto on a box with a real height). Its header is sticky top-0 so it pins as the list scrolls under it. It has overscroll-contain so flicking it to the end doesn’t scroll the app behind it. And, the piece people forget, the sidebar is a flex child, so it needs min-h-0 to actually shrink and scroll instead of growing to fit all its links.

The two failures the target is built to catch are exactly the bugs this lesson named. Leave off overflow-y-auto and the header scrolls away instead of pinning. Leave off min-h-0 and the sidebar never scrolls at all, because it refuses to shrink below its content. Match the target: the header stays pinned, the sidebar scrolls internally, and the top bar and main region never move.

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 four classes you added to the <aside> and its header are the whole pattern: overflow-y-auto makes it a scroll container, overscroll-contain keeps the scroll from leaking, sticky top-0 pins the header inside it, and the min-h-0 already sitting on the parent row is what lets the sidebar shrink enough to scroll at all. Notice the semantics, too. <aside> for the nav region and <main> for the content aren’t decoration: the sidebar’s scroll region is a real landmark, and a screen-reader user navigates straight to it. This exact shell, with chrome that stays put and 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.