CSS layout reference

Everything a scroll container can do.

Most scroll problems are one property away from solved: a panel that drags the page, a carousel that rests between cards, an anchor that lands under the header. Each entry covers what the behaviour is, when to reach for it, and the trap it hides.

overscroll-behavior

Scroll chaining

When a scroller reaches its end and the gesture continues, the scroll passes to the next scrollable ancestor — usually the page. That hand-off is scroll chaining, and overscroll-behavior decides whether it happens. contain stops the chain but keeps the local bounce; none stops both.

overscroll-behavior
page content
panel row 1
panel row 2
panel row 3
panel row 4
panel row 5
panel row 6
page content
page content
page content
page content
page content
page content
page content

Scroll the bordered panel to its end and keep going. With auto the outer frame takes over; with contain or none it stays put.

Use it for

  • Drawers, chat panels, and dropdown lists: reaching the end should not start scrolling the page behind them.
  • Modal bodies, so a long dialog does not drag the document underneath.
  • overscroll-behavior-x on a horizontally panning canvas or carousel, so a sideways swipe does not trigger the browser’s back navigation.
  • overscroll-behavior-y: contain on the root to turn off pull-to-refresh in an app-like page.

Watch for

  • It only acts on an element that is actually a scroll container. On a box whose content fits, there is nothing to contain, and the wheel still scrolls the page.
  • It does not lock the page. The page still scrolls when the pointer is outside the contained element — that job belongs to a scroll lock.
  • Set on body it may do nothing: the viewport takes the value from the root element, so put it on html.
.drawer   { overflow-y: auto; overscroll-behavior: contain; }
.canvas   { overscroll-behavior-x: none; }   /* no swipe-back mid-pan */
html      { overscroll-behavior-y: contain; } /* no pull-to-refresh */

scroll-snap-type

Scroll snap

The container declares an axis and a strictness; the children declare where they align. mandatory always rests on a snap point, proximity only snaps when the scroll ends near one. The scrolling itself stays native, so touch, wheel, and keyboard all keep working.

scroll-snap-type
scroll-snap-align
card 1
card 2
card 3
card 4
card 5
card 6
card 7
card 8

Use it for

  • Carousels and image galleries without a JavaScript slider library.
  • Full-height story or onboarding panels (y mandatory).
  • Horizontally scrolling card rows and date pickers, where a half-visible card looks like a bug.

Watch for

  • mandatory with items taller or wider than the container makes part of an item unreachable. Use proximity when item size is not under your control.
  • A sticky or fixed header covers the snapped item’s edge; give the container scroll-padding equal to the header.
  • scroll-snap-stop: always forces one item per swipe. Without it a fast fling skips several — usually what people want in a gallery, rarely in a wizard.
.strip      { overflow-x: auto; scroll-snap-type: x mandatory;
              scroll-padding-inline: 12px; }
.strip > *  { scroll-snap-align: start; }

IntersectionObserver

Scroll spy

A table of contents that highlights the section being read. The modern build is an IntersectionObserver rather than a scroll listener: the browser reports when a section crosses a band of the viewport, and nothing runs while the reader is merely scrolling.

Overview

overview · paragraph 1
overview · paragraph 2
overview · paragraph 3

Install

install · paragraph 1
install · paragraph 2
install · paragraph 3

Configure

configure · paragraph 1
configure · paragraph 2
configure · paragraph 3

Deploy

deploy · paragraph 1
deploy · paragraph 2
deploy · paragraph 3

Monitor

monitor · paragraph 1
monitor · paragraph 2
monitor · paragraph 3

Use it for

  • Documentation sidebars and long-article tables of contents.
  • Step indicators on a single-page form or landing page.
  • Updating the URL hash as sections pass, so a copied link lands where the reader was.

Watch for

  • Observing whole sections at threshold 0 marks several as active at once. Shrink the root with a negative bottom rootMargin so only a band near the top counts.
  • A short final section can never reach that band. Pad the end of the content, or treat “scrolled to the bottom” as the last item.
  • Inside a scrolling panel the observer’s root must be that panel; the default root is the viewport.
  • Mark the active link with aria-current, not colour alone.
const spy = new IntersectionObserver(
  (entries) => {
    for (const entry of entries)
      if (entry.isIntersecting) setActive(entry.target.id)
  },
  { rootMargin: "0px 0px -75% 0px" } // only the top quarter counts
)
document.querySelectorAll("section[id]").forEach((s) => spy.observe(s))

scrollbar-color

Scrollbar styling

Three standard properties cover most needs: scrollbar-width (auto, thin, none), scrollbar-color (thumb then track), and scrollbar-gutter, which reserves the bar’s space whether or not it is showing. The ::-webkit-scrollbar pseudo-elements allow finer control — radius, hover states, exact width — but only in Chromium and Safari.

scrollbar-width
scrollbar-color
scrollbar-gutter
row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8

Toggle the content length with the gutter on auto, then on stable: on systems with classic (non-overlay) scrollbars, only the first makes the rows change width.

Use it for

  • Tinting scrollbars to the theme so a dark interface does not carry a light system bar.
  • Thin bars inside dense panels, code blocks, and sidebars.
  • scrollbar-gutter: stable on the page or a panel so content does not shift sideways when it grows past the fold.
  • scrollbar-width: none on a snap carousel that has its own arrows or dots.

Watch for

  • In current Chromium, setting scrollbar-color or scrollbar-width to a non-auto value switches the ::-webkit-scrollbar rules off for that element. Scope the pseudo-elements behind @supports not (scrollbar-color: auto) if you ship both.
  • Hiding the bar hides the only sign that content scrolls. Keep another cue — a fade, a partial item, arrows — and never hide it on the main document.
  • A low-contrast thumb is an accessibility failure; hold it to 3:1 against the track.
  • Overlay scrollbars (macOS default, most phones) take no space, so scrollbar-gutter has no visible effect there.
* { scrollbar-width: thin;
    scrollbar-color: var(--thumb) transparent; }
html { scrollbar-gutter: stable; }

@supports not (scrollbar-color: auto) {
  *::-webkit-scrollbar       { width: 8px; height: 8px; }
  *::-webkit-scrollbar-thumb { background: var(--thumb); border-radius: 4px; }
}

scroll-margin-top

Smooth scrolling and anchor offsets

scroll-behavior: smooth animates anchor jumps and scrollTo calls. scroll-padding (on the scroller) and scroll-margin (on the target) move the landing point, which is how a heading avoids ending up underneath a sticky header.

Use it for

  • In-page tables of contents and “back to top” links.
  • scroll-padding-top on html, equal to the sticky header’s height, fixes every anchor on the site at once.
  • scroll-margin-top on headings when only some targets need the clearance.

Watch for

  • Smooth scrolling is motion. Gate it behind prefers-reduced-motion: no-preference.
  • On html it also slows the browser’s find-in-page and long programmatic jumps; some teams apply it only while a link click is being handled.
  • These offsets also shift scroll-snap positions and focus-driven scrolling — usually helpful, occasionally a surprise.
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}
html       { scroll-padding-top: 4.5rem; }   /* sticky header height */
h2[id]     { scroll-margin-top: 1rem; }      /* extra breathing room */

overflow: hidden

Scroll locking

While a modal is open the page behind it should not scroll. The lock is overflow: hidden on the root; the craft is in avoiding the sideways jump when the scrollbar disappears, and in handling iOS, which has historically ignored the simple version.

Use it for

  • Modals, full-screen menus, lightboxes, and command palettes.
  • The native <dialog> with showModal() makes the page inert, but does not stop it scrolling — it still needs the lock.

Watch for

  • Removing the scrollbar widens the page and everything shifts. scrollbar-gutter: stable on html prevents it; the older fix is padding-right equal to the bar’s width.
  • Fixed-position elements shift too unless they sit inside the same gutter.
  • Pair the lock with overscroll-behavior: contain on the modal’s own scroller; otherwise reaching its end rubber-bands the page on touch devices.
  • Restore the previous overflow value on close rather than setting it to empty — something else may have locked first.
html                  { scrollbar-gutter: stable; }
html:has(dialog[open]) { overflow: hidden; }
dialog .body          { overflow-y: auto; overscroll-behavior: contain; }

animation-timeline

Scroll-driven animation

An ordinary CSS animation whose progress is tied to scroll position instead of time. scroll() tracks a scroller from start to end; view() tracks one element’s passage through the viewport. It runs off the main thread, so it stays smooth where a scroll listener would stutter.

Use it for

  • Reading-progress bars.
  • Headers that shrink or gain a shadow once the page has moved.
  • Reveal-on-enter effects without an observer, using view() and animation-range.

Watch for

  • Browser support is still uneven, so treat it as enhancement: wrap it in @supports (animation-timeline: scroll()) and make sure the page reads correctly with no animation at all.
  • animation-timeline must come after the animation shorthand, which resets it.
  • Content that only becomes visible through a scroll animation is invisible where the feature is missing. Animate from a readable state.
  • It is still motion: respect prefers-reduced-motion.
@supports (animation-timeline: scroll()) {
  .progress {
    transform-origin: left;
    animation: grow linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

overflow

Overflow, and what makes a scroller

Every entry on this page acts on a scroll container, and overflow is what creates one. hidden, auto, and scroll all do — hidden simply has no bar, and can still be scrolled by script, focus, or an anchor link. clip is the one value that cuts content off without creating a scroller at all.

Use it for

  • auto for panels: a bar appears only when the content needs one.
  • clip when you only want to cut off a decorative overhang — it leaves position: sticky descendants working, where hidden breaks them.
  • overflow-clip-margin to let a shadow or focus ring extend a few pixels past a clipped edge.
  • overflow-anchor: none on a container whose content you reposition yourself and do not want the browser to compensate for.

Watch for

  • The axes are not independent. Setting overflow-x: hidden turns a visible overflow-y into auto, which is how a “just hide the horizontal overflow” fix produces a second vertical scrollbar.
  • overflow: hidden on body as a fix for horizontal overflow hides the symptom. Find the element that is too wide instead.
  • Scroll anchoring keeps the reader’s place when content loads in above them, but not every browser implements it. For chat logs, pin to the bottom yourself, or reverse the column.
  • A scroller with a bar hidden by overflow: hidden still responds to focus: tabbing to an off-screen input scrolls it, and the layout appears to break.
.panel   { overflow: auto; }
.badge   { overflow: clip; overflow-clip-margin: 4px; }
.chatlog { display: flex; flex-direction: column-reverse; overflow-y: auto; }

background-attachment

Scroll shadows and fades

A cue on the edge of a scroller that says more content lies past it. The CSS-only version layers four backgrounds: two covers in the surface colour that travel with the content, over two shadows that stay fixed to the frame. Wherever there is more to scroll, the cover has moved away and the shadow shows.

scroll shadows
row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8
row 9
row 10

At rest only the bottom edge is shaded. Scroll and the top edge gains a shadow; reach the end and the bottom one disappears.

Use it for

  • Any scroller whose bar is thin, hidden, or an overlay that fades out — which on phones and macOS is every scroller.
  • Tables and code blocks that scroll sideways.
  • Modal bodies, where a shadow under the header separates it from content passing beneath.
  • mask-image with a linear-gradient for a fade to transparency, when the surface behind is not one flat colour.

Watch for

  • The backgrounds sit behind the content, so children with an opaque background paint over the shadow. It suits text and transparent rows; for opaque cards use sticky pseudo-element shadows or a mask.
  • The cover colour must match the surface exactly, in both themes. Use the same token for both.
  • A mask fades the scrollbar too, and fades text at an edge even when nothing lies past it, unless you drive it with a scroll-driven animation.
.scroller {
  overflow: auto;
  background:
    linear-gradient(var(--card) 30%, transparent) top,
    linear-gradient(transparent, var(--card) 70%) bottom,
    radial-gradient(farthest-side at 50% 0, var(--shadow), transparent) top,
    radial-gradient(farthest-side at 50% 100%, var(--shadow), transparent) bottom;
  background-repeat: no-repeat;
  background-size: 100% 36px, 100% 36px, 100% 12px, 100% 12px;
  background-attachment: local, local, scroll, scroll;
}

100dvh

Viewport units on phones

On a phone the browser’s toolbars slide away as you scroll, so “the viewport height” is three different numbers. vh is the large one, which is why a 100vh panel overflows the screen on first load. svh is the viewport with toolbars showing, lvh with them hidden, and dvh tracks whichever is current.

Use it for

  • svh for heroes and anything sized once: it fits on load and never resizes under the reader.
  • dvh for app shells, drawers, and full-screen modals that must end exactly at the visible edge.
  • A vh declaration first as the fallback, then the newer unit on the next line.

Watch for

  • dvh changes while the page scrolls, so everything sized with it re-lays-out mid-gesture. Keep it off large, text-heavy blocks.
  • None of these units account for the on-screen keyboard. A bottom bar sized this way still ends up beneath it.
  • A one-screen app is often better sized from the top down — height: 100% on html and body — than with any viewport unit.
.hero  { min-height: 100vh; min-height: 100svh; }
.shell { height: 100vh;     height: 100dvh; }

content-visibility

Infinite scroll and long lists

Two separate problems share a name. Loading more as the reader nears the end is an IntersectionObserver on a sentinel element. Keeping ten thousand rows cheap is rendering less: content-visibility: auto lets the browser skip layout and paint for off-screen items while leaving them in the DOM.

Use it for

  • Feeds and search results: observe a sentinel with a generous rootMargin so the next page arrives before the reader does.
  • content-visibility: auto with contain-intrinsic-size on long, uniform sections — articles, comment threads, changelogs.
  • JavaScript virtualization (rendering only the visible window) when the list is too large to hold in the DOM at all.

Watch for

  • An endless feed makes the footer unreachable. Put footer links elsewhere, or switch to a “Load more” button after a few pages.
  • JavaScript virtualization removes rows from the DOM, so find-in-page, anchor links, and screen-reader browsing cannot see them. content-visibility keeps all three working.
  • Without contain-intrinsic-size every skipped item counts as zero height, and the scrollbar thumb jumps as items render. The auto keyword remembers the last real size.
  • Going back should return the reader to their place. That needs the loaded pages and the offset kept somewhere — see scroll restoration.
.comment { content-visibility: auto;
           contain-intrinsic-size: auto 12rem; }

const more = new IntersectionObserver(
  ([entry]) => entry.isIntersecting && loadNextPage(),
  { rootMargin: "600px" }
)
more.observe(document.querySelector("#sentinel"))

history.scrollRestoration

Scroll restoration

Pressing Back should put the reader where they were. Browsers do this for the document’s own scroll on ordinary navigations. It stops being automatic the moment the content is loaded late, the scroller is a nested element, or a client-side router is doing the navigating.

Use it for

  • history.scrollRestoration = “manual” when the app restores position itself, so the browser’s attempt does not fight it.
  • Saving scrollTop for nested scrollers — a sidebar, a results pane — keyed by route, since the browser only restores the document.
  • In Next.js, <Link scroll={false}> for tab-like navigation that should leave the scroll position alone.

Watch for

  • Restoration runs before late content has loaded, so the saved offset may not exist yet and the page lands short. Reserve the height, or restore after the data arrives.
  • App shells that scroll an inner element instead of the document give up browser restoration entirely. Prefer letting the document scroll.
  • Scrolling to top on every navigation is right for a new page and wrong for Back. Routers handle the distinction; hand-rolled effects usually do not.
history.scrollRestoration = "manual"

addEventListener("pagehide", () =>
  sessionStorage.setItem(`scroll:${location.pathname}`, String(scrollY))
)
const saved = sessionStorage.getItem(`scroll:${location.pathname}`)
if (saved) scrollTo(0, Number(saved))

scrollIntoView

Focus, keyboards, and scrolling

Focus scrolls. Calling focus() brings the element into view, tabbing does the same, and both honour scroll-margin and scroll-padding. Most surprising jumps trace back to this — and so do most keyboard traps, because a scroller nobody can focus is a scroller a keyboard user cannot scroll.

Use it for

  • focus({ preventScroll: true }) when moving focus into a dialog or menu that is already positioned where you want it.
  • scrollIntoView({ block: “nearest” }) for keyboard navigation in a listbox or command palette: it moves only if the item is out of view, and only as far as needed.
  • tabindex=“0” plus an accessible name on a scrollable region that holds no focusable content, so arrow keys and Page Down can reach it.

Watch for

  • scrollIntoView with the default block: “start” scrolls every scrollable ancestor, including the page. Inside a panel, set scrollTop yourself or use “nearest”.
  • A sticky header covers a newly focused field. scroll-padding-top on the scroller fixes it for focus the same way it does for anchors.
  • Focusing an element inside an overflow: hidden box scrolls the box, shifting the layout with no bar to show why.
option.scrollIntoView({ block: "nearest" })
dialog.querySelector("input")?.focus({ preventScroll: true })

<div tabindex="0" role="region" aria-label="Release notes"
     class="overflow-y-auto">…</div>

Start from the symptom

Nothing here works on my element
Overflow, and what makes a scroller
Hiding horizontal overflow added a vertical scrollbar
Overflow, and what makes a scroller
People cannot tell that a panel scrolls
Scroll shadows and fades
A full-height section overflows on phones
Viewport units on phones
A long list makes the page slow
Infinite scroll and long lists
Back returns to the top of the page
Scroll restoration
The page jumps when focus moves
Focus, keyboards, and scrolling
The page scrolls when a panel reaches its end
Scroll chaining
A sideways swipe on my canvas goes back a page
Scroll chaining
Carousel items stop half-visible
Scroll snap
The sidebar should highlight the current section
Scroll spy
The scrollbar clashes with the theme
Scrollbar styling
Content jumps sideways when it grows past the fold
Scrollbar styling
Anchor links land underneath the sticky header
Smooth scrolling and anchor offsets
The page scrolls behind an open modal
Scroll locking
A bar should fill as the reader moves down
Scroll-driven animation

Keep going

Sticky headers are the other half of scrolling.

The position reference covers sticky and fixed — what they pin to, and why an overflow ancestor quietly breaks them.

Open the position reference

We'd like to use Google cookies to understand how Nodlume is used and to measure our advertising. Nothing loads until you choose, and declining does not affect anything in the app.