scroll_to_if_needed()

scroll-util.mjs

Brings an element into view only when it has actually left the reading zone, and positions it about 38% down the reading zone. Use it for navigation that steps through elements — moving through matches or sections, following a cursor — where scrollIntoView would move the page on every step, including the steps where the target was already comfortably on screen, causing unnecessary movement around the text.

Both axes work this way. The vertical axis moves the scroller you pass. The horizontal axis moves the nearest ancestor of the target that scrolls sideways — a wide table or code block scrolls inside its own wrapper, far below whatever scrolls the page, so that scroller is found rather than given. See The horizontal axis.

This file has no module or configuration dependencies and can be copied into another codebase unchanged. Keep the placement fraction and middle-third visibility band fixed. If a caller needs different positioning, provide a separate function.

Signature

ExportSignatureDescription
scroll_to_if_needed scroll_to_if_needed(el, top_offset, scroller, behavior) Scrolls el into the reading zone on whichever axis it has left; returns whether a scroll was issued
inflight_scroll_target inflight_scroll_target(scroller) The element that scroller is currently scrolling toward, or null when it is at rest

Arguments

ArgumentDefaultMeaning
el—The element to bring into view. null is accepted and returns false, so a caller need not guard a lookup that missed
top_offset0Pixels obscured at the scroller's top by a fixed header or search bar — see The scroller
scrollerthe windowThe window, or an element that scrolls its own overflow. It scrolls the vertical axis, and it is where the search for the horizontal one ends — see The horizontal axis
behaviorchosen — see Smooth or instantForces 'smooth' or 'instant'. Never pass 'auto'

Use the return value to decide whether navigation needs another step. false means the element was already inside the zone and nothing moved — not that the call failed. A caller stepping element by element can loop on it, advancing past the false returns, so one keypress always visibly advances even when the next target already sits in the middle third.

The reading zone and the middle-third test

The zone is what the scroller shows, minus top_offset. A target is left alone when its whole box — top and bottom — sits inside the middle third of that zone. Otherwise, the helper requests a scroll.

The band is generous on purpose. A narrower test fires on almost every step and the page moves too often; a wider band leaves targets near the edge for too long. The middle third is also why the false return exists: in a run of steps a good share of them return false, which is expected when those targets remain comfortably visible.

Each axis is tested against its own zone, and moves on its own. A target inside the middle third of the sideways-scrolling wrapper it sits in leaves the horizontal position exactly as the reader left it, however far the vertical axis travels — and the other way round.

Positioning targets and keeping tall targets visible

When it does scroll, the element's centre lands at 1 − 1/1.618 of the zone's height from the zone's top — about 38 % down. High enough that what follows the element is visible, low enough to keep what came before it. This leaves more room below the target for readers moving forward.

Centering a tall element at the preferred position could hide its first line above the reading zone. The scroll offset is therefore limited so the element's top cannot pass above the zone's top. The requested offset is also never negative. Shorter elements use the preferred position unless the start of the content prevents it.

Measured in a 300 px zone: the calculated offset for a 500 px element was reduced from 1135 px to 1000 px — the offset that puts its top at the zone's top, keeping the first line visible. This avoids the clipping that can occur with scrollIntoView({block: 'center'}), when a tall element appears.

A target longer than about a tenth of the zone does not come to rest inside the band it was tested against. Its centre goes to 38.2 % while the band starts at 33.3 %, so its leading edge stops just outside, and calling again for the same target asks for the offset the scroller is already at. Nothing moves, the call still returns true, and the request keeps its tracking entry. A caller stepping through targets never sees this; a caller that re-requests the current one does.

The scroller

The window by default. Pass an element when the page pins itself to 100dvh and scrolls an inner box instead — an app shell with a fixed header and footer, a side-by-side pane layout, a scroll region inside a dialog.

Keeping the window as the scroller on such a page can fail in two ways:

top_offset follows the same rule — it comes off the scroller's own top, not the viewport's, so it describes a bar sticky inside the scrolling box.

This is also the second reason not to reach for scrollIntoView: it walks up to the nearest scrollable ancestor, which is not always the scroller you meant, and it gives you no way to say which one you did mean. The helper walks for the horizontal axis only, where there is nothing for a caller to name — the section below says where that walk stops.

The horizontal axis

The two axes have different scrollers, so only one of them can be an argument. A page that scrolls its lines in an inner box still lets a wide table scroll sideways in its own wrapper, several levels below that box. The wrapper is what has to move to reach a column off to the side, and a caller stepping through targets cannot know which wrapper a given target landed in. So the helper walks up from the target to find it.

The walk starts at the target's parent and takes the first ancestor that is both:

The walk ends at the scroller you passed, which is its last candidate. Pass an element and the walk never goes above it: a box further out belongs to the page around the reading zone, not to the zone. Pass nothing, and the window is that last candidate — the document's own sideways scroll, which no overflow-x of its own declares, so the document element is asked for its two widths instead.

When nothing in that chain scrolls sideways — the usual case, a sentence in an ordinary paragraph — the horizontal position is not touched at all. The walk follows parentElement, so a shadow boundary ends it: a target inside a shadow tree is reached on the horizontal axis only by a scroller inside that same tree.

One request per scroller. The two axes normally move two different elements, so they issue one scrollTo each, and each one decides smooth or instant for itself. A box that scrolls both ways is given top and left in the same call — two separate smooth requests to one scroller would cancel each other, which is the race described above. Both axes are measured before either request goes out; that is safe, because scrolling up or down moves nothing sideways.

The horizontal offset has no floor at zero. The vertical one is never negative, but a right-to-left container's own lowest scrollLeft is below zero, and the browser clamps every request to the range its scroller actually has.

Positions are read as top and left, so "vertical" and "horizontal" here are the physical axes. In a vertical writing mode the lines run along the axis this page calls horizontal.

Tracking the current target during scrolling

One WeakMap entry per scroller, holding the element that scroller is currently scrolling toward. Two behaviors use this entry.

The race

Chrome cancels both scrolls when a second scrollTo({behavior: 'smooth'}) starts on the same scroller before the first finishes, and the page is left where it started (Chromium issue 40572042; Firefox is unaffected). This occurred in a find-and-replace operation that smooth-scrolled toward the first match and then, a moment later, smooth-scrolled to the match that failed — and the reader saw no scroll at all, even though the action was intended to reveal the problem. The button appeared to do nothing, without reporting an error.

So while an entry is present the next scroll is forced to instant. Two instants do not race, and an instant cancels an in-flight smooth cleanly. scrollend clears the entry, so a later, well-separated scroll is smooth again. The entry is keyed per scroller, which is what keeps one pane forcing instant from touching another — and keeps a second window's listeners its own. A wrapper found for the horizontal axis is a scroller like any other here: it gets its own entry, and a table still gliding sideways does not force the page's next vertical scroll to instant.

A scroll request that leaves the position unchanged retains the tracking entry. The entry is released on scrollend, which fires only when the scroll position actually changed — so a call that returns true while asking for the offset the scroller is already at (a target above the top of the content, clamped to 0, when the scroller is at 0) leaves the entry in place until the next scroll that really moves. Measured: no scrollend in 1.2 s, the entry still there, and the following scroll forced to instant. The consequence is a skipped animation. This explains why a sequence can use instant scrolling even when no scroll appears to be in progress.

Support. scrollend is Chrome 114+, Firefox 109+ and Safari 18.2+ — see Browser support. Where the event does not fire at all the entry is never released, so every scroll after the first stays instant. Scrolling still works, so no polyfill is needed.

Chaining

inflight_scroll_target(scroller) also supports sequential navigation. A caller stepping element by element reads it to pick the next target from the current destination rather than the intermediate scroll position. Every call records its target, including instant scrolls. The entry therefore identifies the most recent target. The overlap check tests whether an entry exists, regardless of whether the earlier scroll was smooth or instant.

Smooth or instant

Smooth by default, decided per scroller. It falls back to instant on any of three:

The fourth argument overrides the choice — but never pass 'auto'. It inherits from CSS scroll-behavior, which can silently turn an intended instant call back into a smooth one, without making that change visible at the call site.

Debugging a scroll that did not happen. If the scroll position does not change synchronously after an instant scroll, the bug is not in this module. Look for overflow: hidden on an ancestor, a transform on a parent, an iframe, or a scroll container that is not the one you passed.

Demo — stepping through blocks

The dashed band is the middle third the zone test asks about; the solid line is where a target's centre is placed. Step forward and watch how many steps issue no scroll at all — around a third of them, on a box this size. Block 7 is taller than the zone, so it is pinned by its top instead of centred. Two in a row fires both steps in the same task, which is the case the race guard turns instant; the second step from a standing start does the same, for the reason under A scroll that does not move holds the entry.

Block 1 — the first one, at the top of the content.

Block 2 — short.

Block 3 — short.

Block 4 — short.

Block 5 — short.

Block 6 — short.

Block 7 — taller than the whole reading zone.

Block 8 — short.

Block 9 — short.

Block 10 — short.

Block 11 — short.

Block 12 — the last one.

Show code

Demo — stepping into a column that is off to the side

The reading box scrolls up and down; the table inside it scrolls sideways in its own wrapper. The call passes the reading box and nothing else, so the wrapper is the one the walk finds. The dashed band is the middle third across the wrapper and the solid line is where a cell's centre is placed.

Step along the row. The first step brings the row down into the reading box and the first column into the wrapper. Every step after it moves the wrapper alone, while the reading box is asked for the offset it is already at — the row is longer than a tenth of the vertical zone, so it never settles inside that band, and the unmoved request is what turns the next scroll instant.

A tall paragraph above the table, so the row can reach the middle third and stay there while the steps below move sideways only.

Eight columns, wider than the reading box
Column 1 Column 2 Column 3 Column 4 Column 5 Column 6 Column 7 Column 8

A tall paragraph below the table.

Show code

Reference

CallBehaviourNotes
scrollTo({top, behavior: 'instant'})InstantCancels an in-flight smooth scroll cleanly. This is the fallback the guard reaches for.
scrollTo({top, left, behavior})Both axes at onceWhat a box that scrolls both ways is given, so its two axes cannot cancel each other's smooth scroll.
scrollTo({top, behavior: 'smooth'})AnimatedSubject to the cancellation race above.
scrollTo({top, behavior: 'auto'})Whatever CSS saysThe default when behavior is omitted. Risky under scroll-behavior: smooth; do not use it.
el.scrollIntoView({block, behavior})Scrolls the nearest scrollable ancestorWalks up the tree, so the scroller is inferred rather than chosen; same race.