24.4 C
New York
Thursday, May 28, 2026

The By no means Ending Story: Constructing a Seamless Infinite Scroll Expertise with GSAP & Lenis



Most digital experiences finish once you cease scrolling.
This one rewards you for not stopping.

On this tutorial, we’re breaking down a small however mighty scroll interplay constructed round infinite looping, parallax depth, and snap-based management. Not from a “have a look at this cool impact” angle, however from the choices that make it really feel easy, intentional, and unusually addictive.

The unique model was created for a consumer undertaking for Jillian Phyllis. The temporary was easy: create one thing light-weight, quick, and MVP-friendly.

Which, as everyone knows, is often the place the hazard begins.

Easy can simply turn out to be empty. Minimal can simply turn out to be forgettable. So the purpose wasn’t so as to add extra, it was to make much less really feel like extra.

The reply was movement.

A seamless loop removes the onerous cease on the backside of the web page. Parallax introduces depth between sections. Snap management provides the expertise rhythm, so the consumer isn’t simply flinging via content material like a buying trolley with one damaged wheel.

Below the hood, the whole lot is powered by Lenis and GSAP. The unique undertaking used Subsequent.js, TypeScript, and Styled Elements, however for this tutorial we’re stripping it again to plain HTML, CSS, and JavaScript.

No framework dependency.
No pointless ceremony.
Simply the interplay, the structure, and the logic behind it.

For this construct, three issues matter:

  1. True infinite looping with out seen seams
  2. Parallax movement that provides depth, not ornament
  3. Snap-based scroll management that feels deliberate

From this level on, we’ll stroll via the implementation step-by-step.

Clear code. Small snippets. No magic tips.

Effectively, perhaps one or two.

[codrops_course_ad id=”115510″]

Structure Overview

Earlier than writing any animation code, it helps to know the form of the system.

We’re making a steady scroll expertise, however the trick is that the web page itself ought to by no means really feel prefer it resets. The loop must occur invisibly.

The core concept is easy:

  • Create fullscreen sections
  • Duplicate the primary part on the finish
  • Allow infinite scrolling in Lenis
  • Snap to every part
  • Animate the media inside every part with GSAP ScrollTrigger

That provides us the muse: the consumer sees a easy, steady web page.

The browser sees a fastidiously staged loop sporting an excellent disguise.

Preliminary Setup

After I first constructed this for LinkedIn, I used my traditional stack: Subsequent.js, TypeScript, and Styled Elements.

For this tutorial, we’re doing the exact opposite.

For this tutorial, we’re stripping it again to plain HTML, CSS, and JavaScript and powering it with Lenis and GSAP.

Your instruments, your guidelines. I’ll information the construction.

HTML

We begin with a couple of fullscreen sections. Three is the candy spot:

  • 2 feels empty
  • 3+ feels intentional

Every part comprises media. Photos, video, canvas, WebGL, take your decide.

The vital half is that this:

We duplicate the primary part and place it on the finish.

That is what permits Lenis to loop seamlessly. With out it, you’ll really feel the reset. With it, the loop disappears.

We additionally conceal this duplicate from assistive tech utilizing aria-hidden so display readers don’t learn it twice.

<head>
    <!-- CSS Reset -->
    <hyperlink rel="stylesheet" href="./property/reset.css">

    <!-- Lenis -->
    <hyperlink rel="stylesheet" href="https://unpkg.com/lenis@1.3.23/dist/lenis.css">

    <!-- Customized Kinds -->
    <hyperlink rel="stylesheet" href="./kinds.css">
</head>

<physique>
    <part class="hero">
        <image class="hero-image">
            <img src="..." alt="Picture 1" />
        </image>
    </part>

    <part class="hero">
        <image class="hero-image">
            <img src="..." alt="Picture 2" />
        </image>
    </part>

    <part class="hero">
        <image class="hero-image">
            <img src="..." alt="Picture 3" />
        </image>
    </part>

    <!-- Duplicate -->
    <part class="hero" aria-hidden="true">
        <image class="hero-image" aria-hidden="true">
            <img src="..." alt="" aria-hidden="true" />
        </image>
    </part>

    <!-- Scripts -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
    <script src="https://unpkg.com/lenis@1.3.23/dist/lenis.min.js"></script>
    <script src="https://unpkg.com/lenis@1.3.23/dist/lenis-snap.min.js"></script>
    <script src="./scripts.js"></script>
</physique>

CSS

Every part fills the viewport.

We use 100svh as a substitute of 100vh to keep away from cellular Safari toolbar points.

Construction-wise:

Part: Fullscreen → Media Container: Fill Container → Media: Fill Container.

.hero {
    place: relative;
    overflow: clip;

    show: grid;
    place-items: middle;

    width: 100%;
    top: 100svh;

    & image, & img {
        show: block;
    }

    & image {
        place: absolute;
        inset: 0;
        z-index: -1;

        & img {
            width: 100%;
            top: 100%;
            object-fit: cowl;
        }
    }
}

JavaScript

We cut up the logic into two elements:

  1. Scroll system
  2. Parallax animation

Lenis Setup

That is the place the loop occurs.

gsap.registerPlugin(ScrollTrigger);

const setupLenis = () => {
    const lenis = new Lenis({
        infinite: true,
    });

    const snap = new Snap(lenis, {
        sort: 'necessary',
        debounce: 500,
        period: 0.9,
        easing: (t) => 1 - Math.pow(1 - t, 4),
    });

    const sections = doc.querySelectorAll('part');

    snap.addElements(sections, {
        align: 'begin',
    });

    lenis.on('scroll', ScrollTrigger.replace);

    gsap.ticker.add((time) => {
        lenis.raf(time * 1000);
    });

    gsap.ticker.lagSmoothing(0);
};

One line does many of the magic:

infinite: true

Every thing else is management and polish.

Parallax

That is the place it goes from “works” to “feels good”.

const sectionAnimation = () => {
    const heros = doc.querySelectorAll('.hero');

    heros.forEach((hero) => {
        const media = hero.querySelector('image');

        gsap.set(media, { yPercent: -50 });

        gsap.fromTo(
            media,
            { yPercent: -50 },
            {
                yPercent: 50,
                ease: 'none',
                scrollTrigger: {
                    set off: hero,
                    begin: 'high backside',
                    finish: 'backside high',
                    scrub: true,
                },
            }
        );
    });
};

We transfer from -50% to 50%.

That’s it.

That slight delay between scroll and media creates depth. Layers begin interacting. The web page stops feeling flat.

You possibly can view a minimal replica of this over on Codepen right here:

Right here’s a video of the impact:

Superior Integrations

As soon as the core is working, you can begin including finesse.

For this undertaking, I layered in a curved marquee utilizing an OSMO element (Curved Marquee). I closely customised it to suit my particular wants, however didn’t reinvent the wheel.

Typically the neatest transfer is understanding when to not write extra code.

Two marquees, completely different speeds, delicate scaling utilizing ScrollTrigger.

Small element. Large payoff.

Last Polish (iOS Repair)

Every thing works… till you open Safari on iOS.

Then the toolbar ruins your day.

Because it expands and collapses, it exposes the loop seam. The phantasm breaks.

So we repair it correctly.

Nested Lenis Setup

We outline our personal scroll container:

HTML

<div class="wrapper">
    <div class="content material">
        <!-- sections -->
    </div>
</div>

CSS

.wrapper {
    place: relative;
    top: 100svh;
    overflow: hidden;
}

JS Integration

We inform Lenis and GSAP to make use of this container:

const wrapper = doc.querySelector('.wrapper');
const content material = doc.querySelector('.content material');

const lenis = new Lenis({
    infinite: true,
    wrapper: wrapper,
    content material: content material,
});

ScrollTrigger.scrollerProxy(wrapper, {
    scrollTop(worth) {
        if (arguments.size) {
            lenis.scrollTo(worth, { quick: true });
        } else {
            return lenis.scroll;
        }
    },
    getBoundingClientRect() {
        return {
            high: 0,
            left: 0,
            width: wrapper.clientWidth,
            top: wrapper.clientHeight,
        };
    },
    pinType: 'remodel',
});

And replace the ScrollTrigger animations:

scroller: wrapper

Now the loop is definitely seamless.

No flicker.
No bounce.
No Safari chaos.

Wrap-up

And that’s the total system: a seamless infinite scroll layered with parallax depth that shifts the expertise from one thing you merely navigate to one thing you really really feel. There’s no heavy structure or over-engineering behind it, only a handful of well-considered concepts working collectively in the appropriate manner.

That’s the true takeaway right here. It’s not often about including extra, it’s about understanding what issues and executing it with intent. Take this, adapt it, push it additional, and construct one thing individuals genuinely wish to maintain scrolling.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles