Lexicon / Scroll behaviour
How scrolling itself behaves · 11 named effects, each running below
Scrolling comes to rest on a whole item instead of halfway between two. It feels like turning pages.
also called snap points · scroll snapping
Two properties, and it inherits the platform's real scroll physics — momentum, rubber-banding, trackpad feel. Every JavaScript reimplementation is worse.
scroll-snap-type: y mandatory;
scroll-snap-align: start;Flick both tracks hard. The top one skips three or four items in one go; the bottom one stops at the very next one.
also called scroll-snap-stop · no-skip snapping · mandatory stop
Use it when every item must actually be seen, and leave it off for browsing. It is the difference between a catalogue and a queue.
scroll-snap-stop: always;normal — a hard flick skips
always — one at a time
The dots under the strip are made by the strip itself, and follow wherever you are in it.
also called ::scroll-marker · CSS-only carousel dots · scroll-marker-group
The dots are generated by the list and track it for free — including keyboard traversal and focus order, which hand-built dot navigation almost never gets right.
scroll-marker-group: after;
li::scroll-marker { content: '' }
li::scroll-marker:target-current { … }The back and forward arrows come out of the row of items itself, and go grey when there is nothing left that way.
also called ::scroll-button() · generated prev/next · CSS-only controls
The same idea as the markers, for the arrows — and the :disabled state at each end arrives free, which is the part hand-rolled carousels forget. No JavaScript, and each one is a real button to a screen reader.
ul::scroll-button(left) { content: '←' }
ul::scroll-button(right) { content: '→' }
ul::scroll-button(*):disabled { opacity: .25 }The moment the bar catches at the top of its panel, it knows, and changes itself.
also called stuck detection · scroll-state container query · sticky-aware header
Until this shipped, every “shrink the header once it sticks” needed a scroll listener and a hand-picked pixel threshold that broke under zoom. Now the element is simply told what it is.
container-type: scroll-state;
@container scroll-state(stuck: top) { … }Whichever slide is parked at the centre knows it is the one you are looking at, and lights up.
also called snapped detection · active-slide styling · scroll-state(snapped)
The companion query. A carousel always needed to know which slide is current, and every hand-rolled version computed it from offsets and got the edges wrong. This is the browser answering the question itself.
scroll-snap-align: center;
@container scroll-state(snapped: block) { … }Going down puts the bar away so you can read. Going back up brings it straight back.
also called auto-hide header · direction-aware nav · scroll-up reveal
Position says where you are; direction says what you want. Descending reads as “reading, leave me alone”, ascending as “looking for something”. A few pixels of dead zone are mandatory, or a trackpad makes it flicker.
dir = y > last ? "down" : "up";
[data-dir="down"] .bar { transform: translateY(-100%) }The list has no end. Keep going and it carries on, with no seam to catch on.
also called endless scroll · recycled list · seamless wrap
The correction must happen in the same frame as the move or the seam shows as a jump. Done right the list has no beginning and no end — and the scrollbar stops telling you where you are, which is the price.
el.append(el.firstElementChild);
el.scrollTop -= rowHeight; /* same frame, or it jumps */Grab it and throw it. It keeps going after you let go, then slows to a stop as though it had weight.
also called drag-to-scroll · inertial scrolling · momentum decay
Everything lives in the decay constant. 0.90 stops abruptly and feels cheap; 0.98 slides forever and feels broken. Around 0.94 is where it starts feeling like an object with mass.
v *= 0.94; /* per frame */
if (Math.abs(v) < 0.1) stop();Whatever has reached eye level is the thing the index marks as where you are.
also called section tracking · active-anchor highlight · reading position
That rootMargin collapses the viewport to a thin band across its middle, so “current” means what is at eye level rather than what merely happens to be on screen. The index pinned to the top of this page is one.
new IntersectionObserver(fn, {
rootMargin: '-45% 0px -45% 0px'
})Push both panels past their last line. One hands your gesture on to the page behind it; the other keeps it.
also called scroll chaining · overscroll-behavior · gesture trapping
One property, and it is the whole difference between a panel that feels solid and one that feels like a leak. The chaining default is right for a document and wrong for almost every overlay.
overscroll-behavior: contain;default — chains
contain — absorbs