惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Recent Announcements
Recent Announcements
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
MongoDB | Blog
MongoDB | Blog
H
Help Net Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
人人都是产品经理
人人都是产品经理
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
V
V2EX
Microsoft Security Blog
Microsoft Security Blog
V
Visual Studio Blog
A
About on SuperTechFans
博客园_首页
L
LangChain Blog
量子位
雷峰网
雷峰网
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Jina AI
Jina AI
月光博客
月光博客
阮一峰的网络日志
阮一峰的网络日志
博客园 - 聂微东
Microsoft Azure Blog
Microsoft Azure Blog
M
MIT News - Artificial intelligence
N
Netflix TechBlog - Medium

Codrops

Drawing With Light: An Exploration of Lit GPU Tubes with TSL and WebGPU | Codrops Building a Real-Time 3D Face Mask with MediaPipe, Threlte and Three.js | Codrops Building an Infinite Loom: Unravelling Images into Threads with Three.js | Codrops Beyond the Luminance Ramp: A Shape-Aware ASCII Renderer in Three.js | Codrops From Rays to Meshes: Building Vercel’s Prism with vgpu | Codrops More Three.js Speakers, More Ideas from Paris | Codrops Breaking the Frame: Building a Real-Time Datamosh Effect with Three.js | Codrops Bend, Aim, Fling: Turning the Eiffel Tower into a Catapult with Three.js | Codrops Volatile Nexus: Tinkering with Glass, Caustics, Cubes and Sound in Three.js | Codrops Goodgrowth: Boot Sequences, Spinning Discs, and the Art of the Portfolio | Codrops Building a Mouse-Following Square Lens Effect with Three.js and GLSL | Codrops Blender to Three.js and Back: 10 Tips for a Better Workflow | Codrops Sixty Frames for the Record: A Three.js Game, Seven Fly-Throughs, and a Wall of CRTs | Codrops Run Rob Run: Building a Music-Reactive Goo with Three.js and WebGPU | Codrops Relighting Images with Depth Maps and Three.js | Codrops Building an Animated Testimonial Hero Using the GSAP Timeline and Dynamic CMS Data | Codrops Creativity at Enterprise-Scale, Without Compromise: The OFF+BRAND. Story | Codrops Inside HAOQI.DESIGN: Letting DOM and WebGL Share a Retro-Futurist Stage | Codrops From Motion to Meaning: Denis Avramenko’s Approach to Interactive Design | Codrops Creating an Interactive 3D Cluster with Three.js, TSL and Three Start | Codrops Exploring Procedural Geometry with Three.js and WebGPU | Codrops From Brand Systems to Cultural Worlds: Inside Antinomy and 27b | Codrops Designing a Flexible Digital Archive for Chems.Studio’s Creative Practice | Codrops The Department Is Open: Building the PX PUSH Website | Codrops Garden Anomaly: A Tiny WebGPU and TSL Experiment | Codrops A Canvas for Individuality: Creating Websites That Feel Unmistakably Human with Readymag | Codrops Building an Endless Interactive Glass Xylophone with Three.js | Codrops The Story Is in the Interaction: Bonhomme’s Digital Experiences for Luxury Brands | Codrops Building an Infinite GSAP Scroll Gallery with Parallax and Flip Transitions | Codrops Studio Freight: Moving Missions Forward | Codrops
The Never Ending Story: Building a Seamless Infinite Scro...
By Joe Ben Taylor · 2026-05-28 · via Codrops

Most digital experiences end when you stop scrolling.
This one rewards you for not stopping.

In this tutorial, we’re breaking down a small but mighty scroll interaction built around infinite looping, parallax depth, and snap-based control. Not from a “look at this cool effect” angle, but from the decisions that make it feel smooth, intentional, and strangely addictive.

The original version was created for a client project for Jillian Phyllis. The brief was simple: create something lightweight, fast, and MVP-friendly.

Which, as we all know, is usually where the danger starts.

Simple can easily become empty. Minimal can easily become forgettable. So the goal wasn’t to add more, it was to make less feel like more.

The answer was motion.

A seamless loop removes the hard stop at the bottom of the page. Parallax introduces depth between sections. Snap control gives the experience rhythm, so the user isn’t just flinging through content like a shopping trolley with one broken wheel.

Under the hood, everything is powered by Lenis and GSAP. The original project used Next.js, TypeScript, and Styled Components, but for this tutorial we’re stripping it back to plain HTML, CSS, and JavaScript.

No framework dependency.
No unnecessary ceremony.
Just the interaction, the architecture, and the logic behind it.

For this build, three things matter:

  1. True infinite looping without visible seams
  2. Parallax motion that adds depth, not decoration
  3. Snap-based scroll control that feels deliberate

From this point on, we’ll walk through the implementation step by step.

Clear code. Small snippets. No magic tricks.

Well, maybe one or two.

Architecture Overview

Before writing any animation code, it helps to understand the shape of the system.

We are creating a continuous scroll experience, but the trick is that the page itself should never feel like it resets. The loop needs to happen invisibly.

The core idea is simple:

  • Create fullscreen sections
  • Duplicate the first section at the end
  • Enable infinite scrolling in Lenis
  • Snap to each section
  • Animate the media inside each section with GSAP ScrollTrigger

That gives us the foundation: the user sees a smooth, continuous page.

The browser sees a carefully staged loop wearing a very good disguise.

Initial Setup

When I first built this for LinkedIn, I used my usual stack: Next.js, TypeScript, and Styled Components.

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

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

Your tools, your rules. I’ll guide the structure.

HTML

We start with a few fullscreen sections. Three is the sweet spot:

  • 2 feels empty
  • 3+ feels intentional

Each section contains media. Images, video, canvas, WebGL, take your pick.

The important part is this:

We duplicate the first section and place it at the end.

This is what allows Lenis to loop seamlessly. Without it, you’ll feel the reset. With it, the loop disappears.

We also hide this duplicate from assistive tech using aria-hidden so screen readers don’t read it twice.

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

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

    <!-- Custom Styles -->
    <link rel="stylesheet" href="./styles.css">
</head>

<body>
    <section class="hero">
        <picture class="hero-image">
            <img src="..." alt="Image 1" />
        </picture>
    </section>

    <section class="hero">
        <picture class="hero-image">
            <img src="..." alt="Image 2" />
        </picture>
    </section>

    <section class="hero">
        <picture class="hero-image">
            <img src="..." alt="Image 3" />
        </picture>
    </section>

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

    <!-- 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>
</body>

CSS

Each section fills the viewport.

We use 100svh instead of 100vh to avoid mobile Safari toolbar issues.

Structure-wise:

Section: Fullscreen → Media Container: Fill Container → Media: Fill Container.

.hero {
    position: relative;
    overflow: clip;

    display: grid;
    place-items: center;

    width: 100%;
    height: 100svh;

    & picture, & img {
        display: block;
    }

    & picture {
        position: absolute;
        inset: 0;
        z-index: -1;

        & img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }
    }
}

JavaScript

We split the logic into two parts:

  1. Scroll system
  2. Parallax animation

Lenis Setup

This is where the loop happens.

gsap.registerPlugin(ScrollTrigger);

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

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

    const sections = document.querySelectorAll('section');

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

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

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

    gsap.ticker.lagSmoothing(0);
};

One line does most of the magic:

infinite: true

Everything else is control and polish.

Parallax

This is where it goes from “works” to “feels good”.

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

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

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

        gsap.fromTo(
            media,
            { yPercent: -50 },
            {
                yPercent: 50,
                ease: 'none',
                scrollTrigger: {
                    trigger: hero,
                    start: 'top bottom',
                    end: 'bottom top',
                    scrub: true,
                },
            }
        );
    });
};

We move from -50% to 50%.

That’s it.

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

You can view a minimal reproduction of this over on Codepen here:

Here’s a video of the effect:

Advanced Integrations

Once the core is working, you can start adding finesse.

For this project, I layered in a curved marquee using an OSMO component (Curved Marquee). I heavily customised it to fit my specific needs, but didn’t reinvent the wheel.

Sometimes the smartest move is knowing when not to write more code.

Two marquees, different speeds, subtle scaling using ScrollTrigger.

Small detail. Big payoff.

Final Polish (iOS Fix)

Everything works… until you open Safari on iOS.

Then the toolbar ruins your day.

As it expands and collapses, it exposes the loop seam. The illusion breaks.

So we fix it properly.

Nested Lenis Setup

We define our own scroll container:

HTML

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

CSS

.wrapper {
    position: relative;
    height: 100svh;
    overflow: hidden;
}

JS Integration

We tell Lenis and GSAP to use this container:

const wrapper = document.querySelector('.wrapper');
const content = document.querySelector('.content');

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

ScrollTrigger.scrollerProxy(wrapper, {
    scrollTop(value) {
        if (arguments.length) {
            lenis.scrollTo(value, { immediate: true });
        } else {
            return lenis.scroll;
        }
    },
    getBoundingClientRect() {
        return {
            top: 0,
            left: 0,
            width: wrapper.clientWidth,
            height: wrapper.clientHeight,
        };
    },
    pinType: 'transform',
});

And update the ScrollTrigger animations:

scroller: wrapper

Now the loop is actually seamless.

No flicker.
No jump.
No Safari chaos.

Wrap-up

And that’s the full system: a seamless infinite scroll layered with parallax depth that shifts the experience from something you simply navigate to something you actually feel. There’s no heavy architecture or over-engineering behind it, just a handful of well-considered ideas working together in the right way.

That’s the real takeaway here. It’s rarely about adding more, it’s about understanding what matters and executing it with intent. Take this, adapt it, push it further, and build something people genuinely want to keep scrolling.