Infinite Scrolling with Lenis
People keep asking about the infinite scroll on this site. It’s one of those things that looks complex but is actually a pretty simple trick once you see the pieces. Here’s how it works.
What is Lenis?
Lenis is a smooth scrolling library from Darkroom Engineering. You’ve probably seen it on award-winning sites without realizing it. It gives you buttery-smooth inertia scrolling and, more importantly for this post, an infinite mode that makes the page loop forever.
The setup is just a few lines:
import Lenis from "lenis";
const lenis = new Lenis({
infinite: true,
});
That’s it. Lenis handles the scroll physics, the wrapping, everything. But there’s a catch: if you just enable infinite: true on a normal page, you’ll see a jarring jump when it loops. The content just… snaps back to the top.
You need to give it something to loop into.
The Trick: Cloning Content
The key insight is duplicating your content. You render two copies of everything: the original, and a clone sitting directly below it. The clone is clipped to the viewport height so there’s always “more content” for Lenis to scroll into seamlessly. When you reach the end of the original, the clone is already there, looking identical to the beginning. Lenis wraps the scroll position, and you never see a seam.
Here’s the HTML structure from my actual homepage:
<div id="original">
<div class="content">
<Intro />
<Employment />
<OpenSource />
<Freelance />
<Writing />
<Contact />
</div>
</div>
<div id="clone" class="h-screen overflow-hidden">
<!-- Same content, repeated -->
<div class="content">
<Intro />
<Employment />
<OpenSource />
<Freelance />
<Writing />
<Contact />
</div>
</div>
Yes, the content is literally copy-pasted in the template. It’s not elegant, but it’s dead simple. The clone’s h-screen overflow-hidden is the secret sauce. It makes sure you only ever see one viewport’s worth of the cloned content, creating a seamless loop.
Here’s a live demo. Scroll inside the box and notice how it loops without any visible seam:
Scroll inside the box — it loops forever.
Scroll Distortion
The infinite scroll alone is nice, but the real flavor comes from what happens to elements as they move away from the center of the viewport. On my homepage, each section gets a subtle 3D tilt and fade as it scrolls off the top. In this demo, I’ve cranked the effect way up so you can really see it. Each word rotates and fades based on its distance from the center:
wow
infinite
scrolling
is
really
cool
wow
infinite
scrolling
is
really
cool
Scroll inside — words distort and loop infinitely.
The trick is measuring each element’s distance from the viewport center, normalizing it to a -1 to 1 range, then mapping that to transform values. Elements near the center sit flat and fully opaque. As they drift toward the edges, they rotate on both axes and fade out.
import { transform } from "motion";
// The callback receives the Lenis instance with
// scroll, velocity, direction, progress, and more.
function applyDistortion(lenis) {
const words = document.querySelectorAll(
"#original .word, #clone .word"
);
const viewportCenter = window.innerHeight / 2;
for (const word of words) {
const wordCenter =
word.offsetTop - lenis.scroll
+ word.offsetHeight / 2;
const distance =
(wordCenter - viewportCenter) / viewportCenter;
const rotateY = transform(
distance, [-1, 0, 1], [-70, 0, 70]
);
const rotateX = transform(
Math.abs(distance), [0, 1], [0, 20]
);
const opacity = transform(
Math.abs(distance), [0, 0.3, 1], [1, 0.5, 0]
);
word.style.transform =
`perspective(1000px) rotateY(${rotateY}deg) rotateX(${rotateX}deg)`;
word.style.opacity = opacity;
}
}
The transform function from motion is doing the heavy lifting here. It maps a value from one range to another. So a distance of -1 (top edge) maps to -70 degrees of Y rotation, 0 (center) maps to 0 degrees, and 1 (bottom edge) maps to +70 degrees. Three lines of code, no math to think about.
Note that the query selector targets both #original and #clone elements. Both copies of the content need the distortion applied, or you’d see a visual discontinuity at the loop point where undistorted clone content meets distorted original content.
The homepage version is more subtle than the demo above. It only kicks in after a section is about 70% scrolled off-screen, applies a very gentle 1-degree tilt with perspective(1200px), and fades to 0.3 opacity (not zero). The effect is barely perceptible on its own, but it makes sections feel like they’re “peeling away” as you scroll past them.
Putting It Together
With autoRaf: true, Lenis handles its own animation loop. You just hook into the scroll event:
import Lenis from "lenis";
const lenis = new Lenis({
infinite: true,
syncTouch: false,
autoRaf: true,
});
lenis.on("scroll", applyDistortion);
That’s really the whole thing. Lenis handles the scroll physics, the cloned content handles the seamless loop, and the distortion callback adds the visual polish.
One last detail that’s easy to overlook: a fixed gradient at the bottom of the viewport. Without it, you’d see the hard edge where the clone clips. The gradient is a one-liner in CSS but it sells the entire illusion:
.scroll-fade {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 72px;
pointer-events: none;
background: linear-gradient(
to bottom, transparent, white
);
}
Taking It Further: Synced Dual Columns
Once you’ve got the infinite scroll pattern down, you can do some really fun things with it. One idea I’ve been playing with: two columns scrolling in opposite directions, synced together. Click any project name below to see it in action.
Click a project to see synced dual-column scrolling.
The trick is running two Lenis instances, one per column, and syncing them inversely. When the left column scrolls down, the right column scrolls up by the same amount. The math is satisfying: subtract the current scroll position from the total scrollable height, and you get the mirror position.
const syncRightToLeft = (e) => {
const invertedPosition =
rightLenis.limit - e.scroll;
rightLenis.scrollTo(invertedPosition, {
immediate: true,
});
};
Each column uses the same original + clone pattern, and each gets its own Lenis instance with infinite: true. The immediate: true flag on scrollTo is crucial. Without it, Lenis would try to smooth-scroll to the synced position, creating a laggy feedback loop instead of a tight lock. You want instantaneous jumps here so the two columns feel mechanically connected, like gears turning together.
Responsive Considerations
This effect is desktop-only. On mobile, infinite scroll feels disorienting and fights with native scroll behaviors (pull-to-refresh, elastic bounce, URL bar hiding). I gate the whole thing behind a media query and listen for resize events so it initializes and tears down correctly if someone resizes their browser window:
const md = window.matchMedia("(min-width: 768px)");
if (md.matches && !lenis) {
initLenis();
} else if (!md.matches && lenis) {
lenis.destroy();
lenis = null;
}
The #clone div is also hidden on mobile with hidden md:block, so the duplicate content doesn’t affect the layout at all on small screens.
Gotchas
A few things I ran into while building this:
syncTouch: false: Lenis’s touch synchronization can interfere with the infinite mode on some devices. Disabling it avoids jank on touch-capable laptops.- Query both containers: If you forget to select
#clone articlein your distortion function, the cloned content won’t have any effects applied and the loop point becomes obvious. - The bottom fade gradient: Without it, you can see the hard edge where content clips. A simple CSS gradient, but the illusion completely falls apart without it.
- Cleanup on navigation: In Astro with view transitions, you need to destroy Lenis on
astro:before-swap, reset any inlineopacity/transformstyles on your sections, and reinitialize onastro:page-load. Forgetting this leaks scroll listeners and leaves sections stuck mid-distortion. I learned this one the hard way.