What scroll actually does

Building a UI with no DOM meant writing scroll from scratch. What looks like one behavior is six separate systems, all of which you inherited for free.

The interface on this site renders into a WebGL texture. There's no overflow: auto in there, because there are no elements. When I needed a list that scrolled, I had to write scrolling.

I assumed that meant subtracting a number from a position. It took about ten minutes to find that scroll is a stack of separate systems, every one of which you inherited for free and none of which you have ever looked at directly.

Here are five of them as switches. Turn them off one at a time.

Five switches

row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8
row 9
row 10
row 11
row 12
row 13
row 14
row 15
row 16
row 17
row 18
row 19
row 20
row 21
row 22
row 23
row 24

Drag the surface, use the wheel, or focus it and press the arrow keys. Your wheel reports no wheel input yet.

Everything still technically works with all five off. It just stops feeling like scrolling. Wheel handling is the sixth and has no switch, because without it nothing moves at all.

Momentum

Releasing a drag doesn't stop the content. Velocity at the moment of release carries forward and decays.

The decay is geometric. Every millisecond, velocity gets multiplied by a constant slightly below 1. UIKit calls that constant the deceleration rate and ships two of them: 0.998 for normal scroll views and 0.99 for the faster one used in paged content.

Deceleration

1000pxtravels
4143msfor

Velocity decays by a constant factor every millisecond. The factor is the entire feel of a fling.

Because the decay is geometric, the total distance has a closed form. Summing v · rate^n over all n gives:

const projectedDistance = velocity / (1 - decelerationRate)

At 2px/ms with a rate of 0.998, that's 1000px. You don't have to simulate the fling to know where it lands, which is what makes velocity projection possible: you can decide at the moment of release whether a sheet should dismiss or snap back, because you already know where the finger was sending it.

The constant is doing all the work here. 0.99 versus 0.998 sounds like a rounding difference and is actually the difference between a scroll that stops where you put it and one that coasts across the whole document.

Rubber banding

At the end of the content, native scroll doesn't stop. It resists.

Overscroll resistance

0finger moved
0block moved
100%passed through

Pull the block to the right. However far you drag, the block cannot pass 230px.

The commonly used approximation, and the one I ended up with:

const resist = (offset: number, dimension: number, c = 0.55) =>
  (1 - 1 / ((offset * c) / dimension + 1)) * dimension

Two properties matter. It's monotonic, so the content always moves a little when you pull, and the surface never feels dead. And it's asymptotic, so no matter how far you drag, the content cannot pass dimension. Drag the slider down to 0.1 and watch the ceiling drop.

That ceiling is the point. A hard clamp tells the user their gesture stopped being received. Resistance tells them they've reached the end while continuing to acknowledge the gesture. Same information, and only one of them feels broken.

Wheel input

Wheel events don't report pixels. They report a number plus a deltaMode saying what unit that number is in: 0 for pixels, 1 for lines, 2 for pages.

Trackpads emit pixel deltas at high frequency. A notched mouse wheel emits chunks. Firefox has historically emitted line-mode deltas where Chrome emits pixels, and a handler that treats deltaY as pixels regardless will scroll roughly a sixteenth as far there.

const toPixels = (event: WheelEvent, viewport: number) => {
  if (event.deltaMode === 1) return event.deltaY * 16 // lines
  if (event.deltaMode === 2) return event.deltaY * viewport // pages
  return event.deltaY
}

The line height of 16 is a guess. There's no correct value, which is why every scroll library carries some version of this constant with an apologetic comment next to it.

Anchoring

Prepend a row to that first demo with anchoring off. Everything you were reading jumps down the screen.

Browsers fixed this years ago with scroll anchoring. When content is inserted above the visible region, the scroll offset is adjusted by the same amount, so the thing you were looking at stays where it is. It's on by default, it's the reason infinite scroll upward is usable at all, and almost nobody knows it's there because it only makes itself visible by failing.

Reimplementing it means picking an anchor element before a mutation, measuring its position after, and correcting the offset by the difference.

Keyboard

Arrow keys, Page Up and Page Down, Home and End, and space to page down. A scroll container gets all of that because the browser gave it to you.

A custom surface gets none of it, and none of it is optional. A scrollable region that can't be reached without a pointer isn't reachable at all for a portion of your users.

The scrollbar

The scrollbar is two pieces of information rendered as one object: where you are, and how much there is. The thumb's position is the first, the thumb's height is the second.

Take it away and a long list becomes a surface with no depth cue. Users can still scroll it. They just have no way of knowing whether they're near the start or the end, and no way of knowing there was more content in the first place.

Worth knowing about

Reimplementing scroll made me realize how much of a scroll container's behavior I had never attributed to anything. Most of it isn't hard. The deceleration is one multiply, the resistance is one formula, anchoring is a measurement and a correction.

What's hard is knowing the list exists. Every one of those behaviors is invisible while it works, and the only reliable way I found to enumerate them was to remove all of them at once and watch which ones I missed.