Skip to content

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.

3 min readThis page as MarkdownSource on GitHub

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-load listener adds an instance, with its own animation frame loop, on every navigation unless you call destroy(): 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.ticker when you pass gsap (with lagSmoothing(0), as in the GSAP example in Lenis’ README), otherwise from its own requestAnimationFrame.
  • ScrollTrigger.update() on every Lenis scroll event when you pass it.
  • On astro:after-swap and astro:page-load: lenis.scrollTo(window.scrollY, { immediate: true, force: true }), which stops an animation still running from the previous page, then lenis.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 lenis

lenis is a peer dependency; the adapter is under 1 kB raw. See view transitions.