Skip to the article

Motion Published Reading time 8 min

Smooth scroll does not break your anchor links — preventDefault does

A fragment click is five browser behaviours in a trench coat, and only one of them is scrolling. Measured in Chromium 151 against a pinned Lenis: the library at its defaults leaves all five intact, and the snippet everyone reaches for to fix it destroys four in a single line.

In short

Smooth scroll rarely stops the page moving — it stops the browser doing the four other things a fragment navigation does. The HTML Standard's scroll-to-the-fragment algorithm sets the document's target element, which is what :target reads, reveals hidden ancestors, scrolls the element into view honouring scroll-margin, runs the focusing steps, and moves the sequential focus navigation starting point. Calling preventDefault cancels all of it at once, and only the scroll is usually put back. Measured in Chromium 151, the canonical snippet loses the hash, drops history.length from 3 to 2, leaves :target null, and sends the next Tab press back to the link the reader just left. Adding pushState recovers the URL and the history entry and recovers neither of the other two.

What to take away

  1. Smooth scroll almost always moves the page; what preventDefault cancels is the hash, the history entry, :target, scroll-margin and the keyboard's place, four of which nobody puts back.
  2. Lenis at its defaults does not intercept the click at all — measured on the pinned 1.1.20 it fails in exactly one state, while an animation of its own is already in flight.
  3. pushState recovers the URL and a Back entry, and it never fires hashchange and never makes :target match, so a highlight rule dies silently the moment a handler intercepts.
  4. scroll-margin-top is a property of scrolling an element into view, not of scrolling to a coordinate, so every smoother that animates a number misses it by the full margin — 120 px on the test page.
  5. This whole class of defect is invisible to tooling: 144 axe scans over 72 documents on this site reported five violations and could not have reported this one, because axe cannot press Tab.

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.

One in-page link, four configurations of the pinned library, plus the browser on its own. Synthetic page, 1,400 px filler, target carrying scroll-margin-top of 120px, viewport 1200 by 800, clicks dispatched from script so nothing else moves the page.
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
The algorithm, verbatim in substance, with what an intercepting handler does to each step. The last two are the ones no snippet reimplements.

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.

Chromium 151.0.7922.34, 2026-08-26. The target is a section carrying scroll-margin-top of 120px whose border-box top is 1,456. The link marked inside sits within the target section; the link marked after is the next focusable element in source order past the one clicked.
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.

src/runtime/scroll/anchors.js:70-96, with the arrow function spelled out. History first, then the move, then the focus — and the same three, without the animation, for a traversal.
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.

src/runtime/scroll/anchors.js:104-131, with the arrow function spelled out as a named one. Twenty-seven lines in the file, eleven of them the actual work.
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
One element declaring scroll-margin-top of 120px, border-box top at 1,456. Chromium 151, 2026-08-26; WebKit 26.5 agrees within a pixel.

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.
Four places the same guarantee is written, and one call that is deliberately not made.

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.

a 200 px jump 731 ms
a 1,000 px jump 1,317 ms
a 3,000 px jump 1,800 ms
How long the old site's anchor animation took, from its own duration curve. The formula is a logarithm of the distance, so a short jump is not much faster than a long one.Reverse-engineered from the pre-rebuild abbod.de; the multiplier that could shorten it occurs zero times in the built output.

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.

Questions

Does Lenis break anchor links?

Not in the way the phrase suggests. Measured against the pinned 1.1.20 in Chromium 151, with the anchors option at its default and the page at rest, an in-page link works completely: the hash is set, :target matches, scroll-margin-top is honoured and Tab continues inside the section. It fails in one state only, while an animation of its own is already running, because the library reads a native scroll back only when it is not mid-animation. On the test page that put the reader 1,164 px past the section with the correct hash in the address bar.

Do my anchor targets need a tabindex of minus one?

Only if you intercept the click. With nothing intercepted, document.activeElement stays on the body element and a single Tab still lands inside the target section, because the browser moved the sequential focus navigation starting point rather than focus. The moment you call preventDefault you cancel that step and have to put it back explicitly. Prefer adding the attribute at click time and removing it on blur over baking it into the markup — a permanent one on the main element has been reported to break the back button on iOS.

Is pushState or replaceState right for an in-page jump?

pushState, if you want Back to behave like a real anchor rather than leaving the page. Two things it does not do, both measured: it never fires hashchange, which the specification states outright, and it does not make :target match, because the document's target element is set only by the fragment algorithm. Use replaceState where you are tracking a position rather than recording a destination — a scroll-spy rewriting the hash as the reader passes headings should not fill the Back button with entries nobody chose.

Can I use scroll-behavior smooth in CSS instead?

Not alongside a JavaScript smoother. It puts a second, uncancellable animator on the same scroll offset and the two fight; the library's own tracker carries a report of it breaking scrolling outright, including the case where a hash in the URL makes the browser's smooth scroll fire on load with no user gesture at all. This site declares scroll-behavior as initial for that reason and performs its reduced-motion jump through the runtime, where it can be cancelled.

Will an accessibility audit catch a broken anchor focus?

No. The last published sweep on this site ran axe-core 4.11.0 over 72 documents at two viewports — 144 scans — and reported five violations, every one a colour value. The record lists what the sweep could not reach, and the first item is the keyboard walk, because axe cannot press Tab. The only instrument that finds this defect is a reading of document.activeElement taken after a real key press.

Sources

Measured in this repository

  • src/runtime/scroll/anchors.js The whole answer in 131 lines: the guard clauses, the pushState written first, the immediate move under reduce, the focus function and the teardown that removes both listeners.
  • src/runtime/scroll/lenis-options.js The eight stated options, the anchors value with its reason, and the ten deliberately inherited defaults the project refuses to restate.
  • src/styles/base/document.css The scroll-behavior initial declaration, with the two-animator collision written beside it as the reason.
  • docs/evidence/legacy-forensics/a05-scroll.md The pre-rebuild site's anchor owner reverse-engineered in full, the 689 anchor count, the duration samples, and the untested trap this article measures.
  • contracts/published-measurements.json The axe-core run: 72 documents, two viewports, 144 scans, five violations, and the list of what it could not reach.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.