scroll_to_if_needed()
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.
| Export | Signature | Description |
|---|---|---|
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 |
| Argument | Default | Meaning |
|---|---|---|
el | — | The element to bring into view. null is accepted and returns false, so a caller need not guard a lookup that missed |
top_offset | 0 | Pixels obscured at the scroller's top by a fixed header or search bar — see The scroller |
scroller | the window | The 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 |
behavior | chosen — see Smooth or instant | Forces '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 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.
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 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:
window.scrollTo is a no-op and the target never arrives.
innerHeight, so a viewport-measured zone counts it as on screen and declines to
scroll at all. Measured: an element at y = 450 in a 900 px viewport, below a
scroller whose box ends at 400, gets a scroll from the element scroller and
false from the window.
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 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:
scrollWidth − clientWidth is more than a
pixel. A pixel is rounding, not a column out of reach.
overflow-x is
auto or scroll. hidden does move under
scrollLeft, but it offers the reader no way to scroll back, so a target hidden
there is left where the page put it.
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.
One WeakMap entry per scroller, holding the element that scroller is currently
scrolling toward. Two behaviors use this entry.
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.
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 by default, decided per scroller. It falls back to instant on any of three:
prefers-reduced-motion: reduce is set.
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.
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.
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.
| Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | Column 6 | Column 7 | Column 8 |
A tall paragraph below the table.
| Call | Behaviour | Notes |
|---|---|---|
scrollTo({top, behavior: 'instant'}) | Instant | Cancels an in-flight smooth scroll cleanly. This is the fallback the guard reaches for. |
scrollTo({top, left, behavior}) | Both axes at once | What a box that scrolls both ways is given, so its two axes cannot cancel each other's smooth scroll. |
scrollTo({top, behavior: 'smooth'}) | Animated | Subject to the cancellation race above. |
scrollTo({top, behavior: 'auto'}) | Whatever CSS says | The default when behavior is omitted. Risky under scroll-behavior: smooth; do not use it. |
el.scrollIntoView({block, behavior}) | Scrolls the nearest scrollable ancestor | Walks up the tree, so the scroller is inferred rather than chosen; same race. |
scrollend event.Element.scrollIntoView.scrollIntoView confused by a smooth scroll-behavior.