Chromium 151 · Lenis 1.1.20 · 2026-08-26
Do smooth-scroll anchor links actually break?
The complaint is easy to find and it is nearly always phrased as a fact: install a smooth-scroll library and in-page links stop working. Measured against this site's exact pin — Lenis 1.1.20, vendored byte for byte — in Chromium 151.0.7922.34, that is false as a general statement, and the shape of the falsehood is the useful part. With the anchors option at its default of false and the page at rest, a click on an in-page link does everything a click is supposed to do.
The URL gains the hash, :target matches, the page settles at 1,336 px with the target's declared scroll-margin-top honoured, and one Tab press afterwards continues inside the section rather than snapping back to the link. Nothing is broken because nothing was intercepted — the library's anchor handler contains no preventDefault at all, and the browser's own fragment navigation runs underneath it.
There is exactly one state in which it fails, and a reader reaches that state in about a second. While a Lenis animation is already in flight, the click arrives mid-curve: on the test page the wheel had set a target of 2,500 px, the click landed at 1,052 px with isScrolling reading smooth, the browser performed its jump to 1,336, and the next animation frame overwrote it. The page settled at 2,500 with the correct hash in the address bar — a URL lying about the reader's position by 1,164 px.
| Configuration | URL after the click | Settles at (px) |
|---|---|---|
| no library, nothing intercepted | hash set, :target matches | 1,336 |
| anchors false, click at rest | hash set, :target matches | 1,336 |
| anchors false, click mid-animation | hash set, :target matches | 2,500 |
| anchors true, click at rest | hash set, :target matches | 1,456 |
| anchors true, click mid-animation | hash set, :target matches | 1,456 |
The mechanism is one line. Lenis reconciles its internal position with the document's real scroll offset inside a guard that requires isScrolling to be false or the string native — lenis.mjs:640 in the vendored copy. Mid-animation the browser's fragment jump is read as noise and discarded, and the frame after it writes the library's own number back. Issue #150 on the project's tracker describes the symptom from outside, in the reporter's words: the page only navigates to the desired location once the scrolling motion stops.
Rows four and five are the cure that carries its own defect. Setting anchors to true fixes the timing and lands the reader 120 px lower than the browser does, because that path resolves an element to its bounding rectangle top plus the animated scroll and never reads scroll-margin-top. One bug traded for another, which is the pattern this whole article is about.
The feel of the animation those rows are riding on — settle frames, peak velocity, and what it costs while parked — is measured in Lenis against native scroll, measured.
HTML Standard §7.4.6.4, read 2026-08-26
What does the browser do when you click a hash?
Five things, and only one of them is scrolling. The HTML Standard's scroll-to-the-fragment algorithm is short enough to read in a minute and it is the specification every hand-rolled handler is competing with, usually without having read it.
The step nobody expects is the last one. Measured on the control row, a native fragment click leaves document.activeElement on the body element — focus does not move to a non-focusable target — and yet the next Tab press lands on a link inside the section rather than after the link that was clicked. The browser moved the sequential focus navigation starting point, which is a separate piece of state that no DOM property exposes.
- Set the document's target element This is the state the :target pseudo-class reads. It is set here and nowhere else — not by the URL, not by pushState, not by setting location.hash from script.
- Run the ancestor revealing algorithm Open a closed details element containing the target, reveal content marked hidden-until-found. A coordinate scroll to an element inside a collapsed disclosure arrives at a target that is still not visible.
- Scroll the target into view With behavior auto, block start, inline nearest — and honouring the element's scroll snap area rather than its border box, which is where scroll-margin enters. This is the only step anyone reimplements.
- Run the focusing steps With the document's viewport as the fallback target. For a plain section element the fallback is what happens, which is why activeElement reads as the body afterwards.
- Move the sequential focus navigation starting point This decides where the next Tab press goes. It is invisible to activeElement, invisible to focus events, and it is the reason a native anchor is already accessible without a tabindex on the target.
- And, outside the algorithm, record the navigation The fragment click is a same-document navigation: the URL changes and a session history entry appears, so Back returns to the previous hash rather than to the previous page.
Hidde de Vries measured the same divergence in 2017 and put it plainly: focus was moved to body, not to the target, and the browser still remembers the linked element's position so subsequent keyboard navigation proceeds from there. The seam is not settled even now — the HTML repository carries three open threads on fragment navigation and focus ordering.
That gap between what activeElement says and what Tab does is how good intentions turn into a regression. A developer writes a test, reads activeElement after a native anchor click, sees the body element, concludes the browser is broken, and fixes it into something genuinely worse. The next section is the measurement of how much worse.
4 handlers · 1200×800 · one Tab press
What does preventDefault cost, exactly?
Four handlers were put on one page, each bound to an identical link pointing at an identical target, and each was measured on six readings: where the page ended, what the URL said, whether a history entry appeared, whether :target matched, what document.activeElement held, and where a single Tab press went next. The last column is the one that never appears in the blog posts.
Row A is the browser with nothing intercepted. Row B is the snippet that comes back first from every search. Row C is the corrected version people ship after someone files a bug about the URL. Row D is the shape this repository uses, in 131 lines of src/runtime/scroll/anchors.js.
| Handler | URL, history, :target | Next Tab lands on | Lands at (px) |
|---|---|---|---|
| A — browser default, nothing intercepted | hash set, length 3, :target matches | the link inside the section | 1,336 |
| B — preventDefault plus scrollIntoView, behavior smooth | no hash, length 2, :target null | the link after the one clicked | 1,336 |
| C — preventDefault plus pushState plus scrollTo | hash set, length 3, :target null | the link after the one clicked | 1,456 |
| D — C plus focus with preventScroll and a transient tabindex | hash set, length 3, :target null | the link inside the section | 1,456 |
Only row D reproduces the browser, and it needs an explicit focus call to do it, because the sequential focus navigation starting point cannot be set from script. Moving real focus to the target is the closest available substitute: it puts the reader inside the section, and it is what this repository's own module comment names as the part every smooth-scroll implementation gets wrong.
Note what row D still does not recover. :target is null in every intercepted row, including the good one, and the page lands 120 px lower than the browser would. Both of those are permanent costs of the interception, and the honest thing to do is name them rather than let the row read as a clean win.
src/runtime/scroll/anchors.js:70-96
Writing the hash back restores the URL and nothing else
pushState rather than replaceState, because Back should return to the previous hash rather than to the previous page, and that is the behaviour a real anchor has. This module writes it first, before the scroll and before the focus move, and the ordering is failure tolerance rather than taste: the URL is the thing a reader copies and a crawler follows, so it is set before anything that can throw.
The two things pushState does not do are both measured. It fires hashchange zero times — the specification is explicit that the URL and history update steps are neither a navigation nor a history traversal — so any listener watching hashchange to react to in-page movement stops firing the day a handler starts intercepting. And it leaves :target null, in Chromium 151 and WebKit 26.5 alike, because the document's target element is set by the fragment algorithm and by nothing else.
event.preventDefault()
// History FIRST, so the URL is correct even if anything below throws, and
// so Back returns to the previous hash rather than to the previous page.
if (view.location.hash !== `#${id}`) {
view.history.pushState(null, '', `#${id}`)
}
// Under `reduce` this is a jump with no interpolation — the same journey,
// without the travel.
scroll.scrollTo(target, { immediate: env.prefersReducedMotion })
moveFocusTo(target)
// Back and Forward between hashes must move the page too: the pushState
// above means the browser will not scroll for us.
function onPopState() {
const id = decodeURIComponent(view.location.hash.slice(1))
if (!id) return
const target = doc.getElementById(id)
if (!target) return
scroll.scrollTo(target, { immediate: true })
moveFocusTo(target)
}
The popstate handler exists because pushState took the traversal away from the browser, and it uses an immediate move rather than an animated one on purpose: a Back is not a journey, it is a correction, and animating it makes the reader watch a thousand pixels of scenery to undo a mistake. The specification's language here is permissive in both directions — a user agent should attempt to restore an entry's scroll position, and should return without restoring once the document has been scrolled by the user — which is exactly why writing the handler is defensible rather than redundant.
There is a narrower case worth separating. A scroll-spy that rewrites the hash as the reader passes each heading should use replaceState, not pushState: it is tracking a position rather than recording a destination, and filling the Back button with twenty entries the reader never chose is its own defect.
src/runtime/scroll/anchors.js:104-131
How do you move focus without fighting your own animation?
Three details, and each one exists because of a measured failure. The tabindex of minus one is added only when the element cannot already take focus, so a link or a button target is not given an attribute it does not need. The focus call passes preventScroll, because without it the browser teleports to the element instantly and the animation just started has nothing left to animate. And the attribute is removed again on blur, so the DOM is not left carrying state the runtime wrote and never cleaned up.
That last one is a rule in this repository's accessibility contract, K-8, and it is a rule because of a defect on the old site: the menu panel's tabindex of minus one was added on open and never reversed. There is an external argument for the same shape — a permanent tabindex of minus one on the main element has been reported to break the back button on iOS, which is a good reason to hold the attribute only for as long as the focus lasts.
function moveFocusTo(target) {
// Only borrow focusability the element does not already have.
const focusable =
target.hasAttribute('tabindex') || isNativelyFocusable(target)
if (!focusable) target.setAttribute('tabindex', '-1')
// preventScroll is load-bearing: without it the browser jumps to the
// element and the animation this module started has nothing to animate.
target.focus({ preventScroll: true })
// K-8: anything the runtime writes, the runtime removes.
if (!focusable) {
target.addEventListener('blur', function drop() {
target.removeAttribute('tabindex')
}, { once: true })
}
}
Whether the moved focus draws a ring turned out to depend on how the link was activated, and the engines disagree. In Chromium 151 a mouse click leaves the target focused with :focus-visible false and no outline, and Tab followed by Enter leaves it focused with :focus-visible true and the outline drawn — which is the behaviour you want, arrived at without writing a rule for it. Headless WebKit 26.5 drew the ring on the mouse path as well; that divergence is unverified against shipping Safari and Firefox was not measured at all, so treat the Chromium row as the measured one and the rest as open.
The contract this module is written against has a rule for that too. K-7: focus moved by the runtime must be provable by reading document.activeElement after a real key press, never by the fact that focus was called. The old site paid for that sentence with a thirty-frame retry loop that existed only because focus silently no-ops on an element transitioning through visibility hidden — half a second of polling to work around a call that reported success and did nothing.
3 declarations · 120 px · 2026-08-26
Why does the section still land under the fixed header?
Because scroll-margin is a property of scrolling an element into view, and a coordinate scroll is not given an element. The CSS Scroll Snap specification asks user agents to use the element's scroll snap area rather than just its border box when deciding what to bring into view, even when snapping is off — which covers the browser's own fragment navigation and covers scrollIntoView. Passing a number to window.scrollTo hands the browser nothing to apply a margin to.
The gap is exactly the declared margin, and it is the same gap in both engines that were measured.
- 1,336 deep link and scrollIntoView both honour the scroll snap area
- 1,456 scrollTo given a coordinate a number has no margin to read
- 120 the gap, in pixels exactly the declared value, every time
- 3 declarations in this repository and the click path reads none of them
This is the section where the article names a defect in its own house. All three declarations here sit in section stylesheets — the legal documents and the case-study pages — and the comment beside one of them says why: declared on the sections rather than in the scroll runtime so it holds with nothing running. That reasoning is right, and it is precisely why the enhanced click path misses them. Every one of those declarations reaches a deep link, a reload and a page with JavaScript off, and none of them reaches a click, because the click ends in a scroll to a resolved coordinate with no offset passed. Confirmed by reading both code paths; the size of the gap on the live routes has not been measured, only the 120 px on the synthetic page.
The old site's forensic audit predicted this exact shape and filed it as an untested trap: an in-page click and a deep link to the same id land at different offsets, because the click path uses a computed header height and the CSS uses scroll-margin-top. Two offset systems that never combine.
And if you do subtract the header yourself, measure it rather than assuming it. This repository's spacing token was believed to be the header's height until it was checked: the bar measured 79.2 px against the token's 70.1 px at 390 wide, and 68.8 px against 59.1 px at 768. A contact page built on the assumption rendered underneath the bar.
The case-study pages are where those declarations do most of their work — eight chapter anchors and a sticky rail on each of the ten builds.
src/runtime/scroll/scroll-controller.js:37-42
What does reduced motion change about an anchor?
It removes the travel and keeps the journey. Under a reduce preference the anchor still moves the page, still writes the hash, still moves focus — it simply arrives instantly, and the guarantee is made in four independent places so that no single one of them can be the thing that fails.
The reason for the redundancy is that a smooth scroll under reduce is not a cosmetic overreach; it is the specific motion the visitor asked not to see, delivered by the one interaction on the page that is guaranteed to travel a long distance.
- immediate on the anchor path
- The click handler passes the reduce preference straight through as the immediate flag, so the same code path serves both cases and there is no second branch to forget.
- behavior auto, never smooth
- The controller's native path scrolls with behavior auto and never smooth, because a smooth programmatic scroll re-introduces exactly what the preference asked to remove.
- scroll-behavior at initial
- The document stylesheet declares scroll-behavior as initial rather than smooth, so there is no second, uncancellable animator on the same offset for the runtime to fight.
- the reset layer, with a bang
- The blanket reduce block lives in the reset layer so nothing can accidentally out-specify it, and it forces scroll-behavior to auto with an important flag.
- no library at all
- Under reduce this project never constructs the smooth scroller. There is nothing running to make an anchor glide, which is the strongest form the guarantee can take.
The old site honoured the same preference by a route worth remembering. It polled six hundred animation frames waiting for a global to appear and then destroyed the instance — because the obvious call, stopping it, was measured to leave the page completely unscrollable: zero pixels on the wheel, zero pixels on the End key. A preference implemented as a teardown rather than a pause, because pausing was worse than the motion.
The same preference has a much harder job where the motion is a canvas rather than a scroll offset — that argument is in reduced motion for WebGL, Rive and canvas.
a05-scroll.md §12 · 689 anchors
What should you refuse to build?
Four negatives, each with a measurement behind it rather than a preference. The first is the one that costs the most to discover late: never put two animators on one scroll offset. The old abbod.de did, and the reverse-engineered specification of the third-party module it shipped is worth reading precisely because that module was competent.
It called preventDefault and stopPropagation, it wrote the hash with pushState, it measured the fixed header and offset for it, it moved focus with preventScroll and it restored the tabindex afterwards. It did all four of the things this article says a handler owes you. What it could not do was know about the other animation loop writing the same scroll position on the same page, across 689 hash anchors.
The third negative is about instruments. This site publishes its accessibility number rather than claiming a clean one: axe-core 4.11.0 over 72 documents at two viewports, 144 scans, five violations, all of them colour values on a single element. None of them could have been an anchor that moves the page and not the focus, and the measurement record says why in its own list of what was not covered — the keyboard walk, because axe cannot press Tab. A link that leaves a keyboard user behind passes every automated checker there is.
The fourth is about how much of this you should build at all. The scroll subsystem here is registered as important and never as essential, and the anchor module's header states the test: delete this module and every anchor on the site still works. Whatever you add to a fragment click should be an enhancement over a primitive that already functions, because the primitive is the one thing you cannot make more correct — you can only make it feel different, and cost yourself four behaviours doing it.
The five surviving violations in that sweep, and the 707 that turned out to be an artefact of measuring mid-animation, are unpacked in axe contrast false positives.
Accessible keyboard behaviour is one of the four things this studio treats as part of the build rather than an audit afterwards — see the four disciplines.