# 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](https://github.com/withastro/astro/issues/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](https://github.com/withastro/astro/issues/12725),
[darkroomengineering/lenis#348](https://github.com/darkroomengineering/lenis/issues/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:

```ts
// 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`](/resources/guides/gsap-scrolltrigger-astro-view-transitions/)
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

```sh
npx astro add moonarc
npm i lenis
```

`lenis` is a peer dependency; the adapter is under 1 kB raw. See
[view transitions](/docs/view-transitions/).