Resources / Guide · 2026-09-12
Lenis smooth scroll with Astro's ClientRouter
A navigation made while Lenis is still easing scrolls the next page to the old page's target. lenisPage stops the animation and resyncs Lenis after every ClientRouter swap.
The symptoms
These apply only to sites that use <ClientRouter />. We measured them with
Lenis 1.3.26 on Astro 7.3.2 in Chromium and Firefox.
- The next page opens part-way down. Click a link, or press back, while Lenis is still easing from a wheel scroll, and the animation keeps running after the router has scrolled the new page. A navigation 120 ms into a 1200 px wheel scroll left the next page at 1200 px instead of the top. A 2023 report describes a next page that does not start at the top (withastro/astro#7758).
- Instances pile up. Creating Lenis inside an
astro:page-loadlistener adds an instance, with its own animation frame loop, on every navigation unless you calldestroy(): three instances after two navigations.
When Lenis is at rest during a navigation, it follows the router by itself: the next page starts at the top, and the back button restores the old position.
Reports from 2024 also show Firefox logging “Too many calls to Location or
History APIs within a short timeframe”, because the router saved the scroll
position on every scrollend event and Lenis’ scrolling fired one after every
scroll event
(withastro/astro#12725,
darkroomengineering/lenis#348).
We could not reproduce it with Lenis 1.3.26 and Astro 7.3.2: ten wheel ticks
made one history.replaceState call.
Why it happens
<ClientRouter /> keeps the document and swaps its contents, then scrolls the
window to the top or to the position it restores. Lenis keeps its own target
and animation across the swap. While an animation runs, Lenis ignores native
scroll events and keeps writing its own position, so the new page ends up
where the old page’s scroll was heading. Lenis 1.3.26 has a
stopInertiaOnNavigate option for this, off by default. It reacts to clicks
on links, and in our test the back button still carried the animation over.
The fix
Create Lenis once for the life of the document, and stop and resync it after
every swap. lenisPage does that:
// src/layouts/Base.astro — inside <script>
import Lenis from 'lenis';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { lenisPage } from '@moonarc/core/lenis';
const lenis = lenisPage(() => new Lenis(), { gsap, scrollTrigger: ScrollTrigger });- One instance, kept across navigations.
lenis.get()returns it anywhere. - Driven from
gsap.tickerwhen you passgsap(withlagSmoothing(0), as in the GSAP example in Lenis’ README), otherwise from its ownrequestAnimationFrame. ScrollTrigger.update()on every Lenis scroll event when you pass it.- On
astro:after-swapandastro:page-load:lenis.scrollTo(window.scrollY, { immediate: true, force: true }), which stops an animation still running from the previous page, thenlenis.resize()for the new page’s height. This covers clicked links and the back button. - Then Lenis’ classes (
lenis,lenis-smooth,lenis-stopped) go back on<html>. The swap gave<html>the new page’s classes, and Lenis writes its own only when its state changes, so without this they stay missing until the next scroll.
Pair it with gsapPage
for the animations themselves.
How the fix is tested
A browser test scrolls page A to 1500 px through Lenis, navigates, and asserts
the same Lenis instance is alive, the window is at 0, Lenis’ internal target is
0, the scroll limit reflects the taller page, and the lenis class is back on
<html>. A second test navigates
120 ms into a 1200 px wheel scroll, once by a link and once by the back button,
and asserts the page stays where the router put it. Without the stop in
lenisPage, it fails on both navigations. Both tests run on Astro 5, 6 and 7.
Install
npx astro add moonarc
npm i lenislenis is a peer dependency; the adapter is under 1 kB raw. See
view transitions.