Lexicon / Scroll behaviour

Scroll behaviour

How scrolling itself behaves · 11 named effects, each running below

  1. 14Scroll Snap
  2. 15Snap Stop
  3. 16Scroll Markers
  4. 17Scroll Buttons
  5. 18Sticky State Query
  6. 19Snapped State Query
  7. 20Direction State
  8. 21Infinite Loop Scroll
  9. 22Kinetic Drag
  10. 23Scrollspy
  11. 24Overscroll Containment
14 / Scroll behaviour

Scroll Snap

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.

How it is builtscroll-snap-type: y mandatory; scroll-snap-align: start;
Open its page
scroll inside ↓
Snap 01
Snap 02
Snap 03
Snap 04
15 / Scroll behaviour

Snap Stop

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.

How it is builtscroll-snap-stop: always;
Open its page
flick both

normal — a hard flick skips

    always — one at a time

      16 / Scroll behaviour

      Scroll Markers

      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.

      How it is builtscroll-marker-group: after; li::scroll-marker { content: '' } li::scroll-marker:target-current { … }
      Open its showcase
      drag · or click a dot
        17 / Scroll behaviour

        Scroll Buttons

        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.

        How it is builtul::scroll-button(left) { content: '←' } ul::scroll-button(right) { content: '→' } ul::scroll-button(*):disabled { opacity: .25 }
        Open its page
        press the arrows
          18 / Scroll behaviour

          Sticky State Query

          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.

          How it is builtcontainer-type: scroll-state; @container scroll-state(stuck: top) { … }
          Open its page
          scroll inside ↓
          Momoyogusa · plate indexstuck
          19 / Scroll behaviour

          Snapped State Query

          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.

          How it is builtscroll-snap-align: center; @container scroll-state(snapped: block) { … }
          Open its page
          scroll inside ↓
            20 / Scroll behaviour

            Direction State

            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.

            How it is builtdir = y > last ? "down" : "up"; [data-dir="down"] .bar { transform: translateY(-100%) }
            Open its page
            scroll inside ↕
            Momoyogusaup
            21 / Scroll behaviour

            Infinite Loop Scroll

            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.

            How it is builtel.append(el.firstElementChild); el.scrollTop -= rowHeight; /* same frame, or it jumps */
            Open its page
            never ends ↓
            22 / Scroll behaviour

            Kinetic Drag

            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.

            How it is builtv *= 0.94; /* per frame */ if (Math.abs(v) < 0.1) stop();
            Open its page
            grab and throw
            23 / Scroll behaviour

            Scrollspy

            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.

            How it is builtnew IntersectionObserver(fn, { rootMargin: '-45% 0px -45% 0px' })
            Open its page
            scroll inside ↓
            24 / Scroll behaviour

            Overscroll Containment

            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.

            How it is builtoverscroll-behavior: contain;
            Open its page
            scroll both to the end

            default — chains

            contain — absorbs