Enhance User Experience with an Infinite GSAP Gallery and Dynamic Scroll Effects

Jul 30, 2026 880 views

Creating an Unbounded GSAP Gallery with Dynamic Scroll Effects

Explore how to develop a limitless GSAP gallery featuring dynamic scrolling, engaging animations, and immersive fullscreen transitions.

Various gallery styles have become staples across different websites, catering to portfolios and agency showcases alike. In this in-depth tutorial, I’m going to guide you through creating an infinite scrolling gallery where each image possesses distinct motion speed, contributing to a buoyant floating appearance. When an image is clicked, it transitions smoothly from its grid position into a focused detail view.

The structure of this tutorial includes three main sections:

  • Slider (Scroller): An infinite scroll function based on both mouse wheel and touch interactions, featuring parallax effects for each item.
  • Reveal: This section implements fading for items entering the viewer’s perspective, ensuring a smooth appearance without abrupt snapping.
  • Transition: A morphing effect that expands a clicked item to showcase detailed content.

Throughout this process, I’ll exclusively use GSAP and standard JavaScript. The tutorial employs Observer for input handling, GSAP Flip for transition morphs, and SplitText for reveal animations.

1. Templating the Gallery

HTML Structure

First, let’s construct the HTML for the gallery:

<div class="gallery">
  <figure class="gallery__slide">
    <div class="gallery__img-wrapper">
      <img class="gallery__img" src="..." alt="Cliffs descending into a vibrant blue cove" />
    </div>
    <figcaption>Above the Cove</figcaption>
  </figure>
  <!-- Additional slides here -->
</div>

Next, the detail view will be contained in a separate overlay that remains hidden until a gallery item is clicked:

<div class="content">
  <div class="content-wrapper">

    <figure class="content__preview-img"><img alt="" /></figure>

    <div class="content__group-list">
      <button class="content__back" type="button">Back (Esc)</button>

      <div class="content__group" data-index="0">
        <div class="content__title">Above the Cove</div>
        <div class="content__description">...</div>
      </div>

      <!-- One content group per slide -->
    </div>

  </div>
</div>

Each group corresponds with a gallery item using data-index. When an image in the gallery is clicked, that item's content group becomes the active display. Importantly, the transition doesn’t involve creating new markup; it merely swaps visibility between content groups along with updating the preview image source. The <figure class="content__preview-img"> serves as the destination for every transitioning thumbnail.

Styling Considerations

Now, let’s shift to CSS. Since we'll override the conventional scroll behavior, we need to disable the native scrolling and arrange the display of each item properly:

html, body {
  overflow: hidden;
  overscroll-behavior: none;
  touch-action: none;
}

.gallery {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12vh;
  padding-top: 14vh;
}

.gallery__slide {
  transform: translateX(var(--stagger, 0vw));
}

.gallery__slide:nth-child(1) { --stagger: -24vw; --img-w: 190px; }
.gallery__slide:nth-child(2) { --stagger:  14vw; --img-w: 260px; }
/* Include additional lines for each slide */

.gallery__img-wrapper {
  width: var(--img-w, 200px);
  aspect-ratio: 0.8;
  overflow: hidden;
}

Each gallery__slide is assigned two custom properties: --stagger, which offsets the item from the center, and --img-w, exclusively designated for width. This approach allows for easier maintenance, particularly when resizing the gallery, as we avoid overwriting JavaScript transform values.

2. Developing the Slider

As mentioned earlier, this component maintains the scrolling position, prompting the other modules to react accordingly.

Implementing Infinite Scroll

A key strategy is to forego the browser’s default scrolling to manage the gallery's movement. Instead, I create a paused timeline where each slide completes a full circuit, using its timeline playhead to simulate scroll position.

In the accompanying code, we have verticalLoop, a helper function that establishes this timeline. Inspired by the GSAP’s seamlessLoop helper, it's adapted for vertical scrolling.

This function generates two tweens for each item: one for exiting the top of the screen and another for re-entering from the bottom of the gallery.

// Two tweens per item: one exits at the top, the other re-enters from below
for (let i = 0; i < length; i++) {
  const item = items[i];

  // Current offset in px
  const currentY = (yPercents[i] / 100) * heights[i];

  // Distance to the container's top edge
  const distanceToStart = item.offsetTop + currentY - startY;

  // Distance until item is fully off-screen at the top
  const distanceToLoop =
    distanceToStart + heights[i] * gsap.getProperty(item, "scaleY");

  // Slide upward until fully off-screen
  tl.to(
    item,
    {
      yPercent: snap(((currentY - distanceToLoop) / heights[i]) * 100),
      duration: distanceToLoop / pixelsPerSecond,
    },
    0,
  ).fromTo(
    // Then re-enter from the underneath
    item,
    {
      yPercent: snap(
        ((currentY - distanceToLoop + totalHeight) / heights[i]) * 100,
      ),
    },
    {
      yPercent: yPercents[i],
      duration: (totalHeight - distanceToLoop) / pixelsPerSecond,
      immediateRender: false,
    },
    distanceToLoop / pixelsPerSecond,
  );
}

Using yPercent for animation positions helps ensure calculations remain relative to each item's own height, which is especially useful if items feature different dimensions. However, we deploy the y property for parallax effects.

We also apply immediateRender: false on the fromTo tween to prevent GSAP from setting an initial bottom value prematurely. This is crucial, as otherwise, items would jump unexpectedly to the base of the gallery on initial rendering.

For the timeline itself, the final line ensures that every tween renders during the initial phase:

// Pre-rendering all tweens to eliminate first-frame jumps
tl.progress(1, true).progress(0, true);

This process generates all tweens in advance, eliminating any that might initialize on the first actual frame of animation.

As for duration, it’s determined by the distance each item travels, ensuring a consistent speed, irrespective of varying heights:

// Constant travel rate: speed 1 = 100px per second
const pixelsPerSecond = (config.speed || 1) * 100;

This means duration: distanceToLoop / pixelsPerSecond. If it seems confusing, consider that our timeline is paused: true and is never played directly. Thus, this factor establishes a conversion rate: one second of playhead time corresponds to 100px of movement. GSAP uses seconds as its default temporal measurement for timelines.

In practical terms, we’re performing plain time = distance / speed calculations. If an item needs to traverse 400px, it’s assigned a duration of 4; for 800px, it’s 8. This uniformity means each slide advances at the same velocity, preventing differing-sized items from drifting apart as they progress. Across twelve slides, our gallery spans about 5,200px, resulting in a loop duration nearing 52 seconds—definitely much longer than any viewer would wait for.

So why choose 100 instead of another figure? This value ensures a one-to-one correlation with the scrolling wheel, a concept that we'll soon revisit in the slider’s scroll() method, where we divide scrolled pixels by that same 100. If you scroll 300px, the playhead shifts by 3, moving the gallery a distance of 300px.

Don't be mistaken; those two instances of 100 represent the same number across two separate files. The property config.speed may look like an adjustable parameter, but if you set speed: 2, the gallery functions at 200px per second. Meanwhile, the scroll() method still divides using the original 100, meaning a 300px scroll would yield a 600px gallery move. Thus, you’d effectively amplify sensitivity without escalating speed.

Achieving Endless Scrolling

The timeline can loop, but the playhead may extend beyond 0 and the maximum duration. To accommodate this, we use gsap.utils.wrap to constrain any out-of-range values:

/** Create a continuous vertical loop for the gallery slides */
createLoop() {
  const gallery = document.querySelector(".gallery");

  // The gap between slides serves as the loop's bottom padding
  const gap = parseFloat(getComputedStyle(gallery).rowGap);

  // A paused timeline for a complete circuit; its playhead functions as our scroll position
  this.loop = verticalLoop(".gallery__slide", {
    repeat: -1,
    paused: true,
    paddingBottom: gap,
  });

  // Wrapping playhead values ensures seamless endless scrolling
  this.wrap = gsap.utils.wrap(0, this.loop.duration());
}

For instance, this.wrap(-3) on a 52-second loop brings back 49. This way, the playhead value can fluctuate significantly, whether positive or negative, without ever compromising its function within the timeline.

Regarding the bottom padding paddingBottom: gap, we pass it the rowGap so that it interprets 12vh from the CSS. However, within the helper function, we calculate totalHeight using offsetTop, which is measured from the document and not the gallery box. As a result, the gallery’s own padding-top: 14vh is inadvertently included in the total height before the extra paddingBottom is added.

This creates additional space at the transition point, where the final slide leaves and the first slide re-enters—totaling 14vh + 12vh = 26vh. This effectively creates a more spacious transition than other segments of the gallery, which maintain only 12vh of separation, enhancing the user experience during the transition.

Implementing Scrub Proxy for Motion

The final component contributing to the gallery's tactile experience is animating a proxy object instead of directly manipulating the time from the scroll wheel. This approach provides smoother animations:

/** Establish an eased playhead to create a smooth scroll interaction */
createScrub() {
  // Proxy object representing the current scroll position
  this.playhead = { time: 0 };

  // Smoothly animates playhead toward the target scroll
  this.scrub = gsap.to(this.playhead, {
    time: 0,
    duration: 0.75,
    ease: "power3.out",
    paused: true,
    onUpdate: () => {
      this.loop.time(this.wrap(this.playhead.time));
      this.applyParallax();
    },
  });
}

This code allows us to embed inertia, manage interruptions, and maintain a single rendering callback without requiring manual lerp loops or async frame requests.

Triggering this system is surprisingly concise:

/** Update the scrub target based on user scroll input */
scroll({ deltaX, deltaY }) {
  if (!this.enabled()) return;

  // Determine whether the swipe is primarily horizontal or vertical
  const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;

  // Our slides translate 100px per second, so converting pixels to time
  this.scrub.vars.time += delta / 100;
  this.scrub.invalidate().restart();
}

A GSAP tween captures its initial values upon first render. The invalidate() method discards those values, allowing the tween to read the latest playhead.time as its start point. Subsequently, restart() replays the easing for the duration of 0.75 seconds, transitioning toward the newly targeted position. The result is a highly responsive system, where rapid scrolling can retarget an existing tween seamlessly, delivering precisely the momentum effect we strive for.

Regarding the calculation delta/100, since the gallery covers 100px per second, dividing the distance scrolled by 100 effectively translates it into playhead time, achieving synchronized movement as the gallery responds directly to user inputs.

Observer for Input Normalization

Utilizing Observer allows us to condense wheel, touch, and pointer events into a unified API:

/** Set up observer for wheel and touch inputs */
createObserver() {
  this.observer = Observer.create({
    target: window,
    type: "wheel,touch",
    preventDefault: true,
    onChange: (self) => {
      this.scroll(self);
    },
  });
}

The preventDefault: true setting keeps our CSS from being overwritten. The overflow: hidden directive prevents page scrolling; however, the browser still activates wheel and touch events along with their default behaviors—like refreshing, rubber banding, or back swiping. By canceling these events at this level, we ensure the gesture’s sole impact is on our scrubbing logic and nothing else.

Creating Seamless Parallax Effects

Every slide in the gallery receives its unique speed multiplier, cycled from a predetermined array:

/** Assign distinct travel speeds to each slide for added depth */
createParallax() {
  // Speed multipliers: > 1 indicates faster, < 1 denotes slower
  const speeds = [1.3, 0.8, 1.15, 0.7, 1.25, 0.85];

  this.parallax = gsap.utils.toArray(".gallery__slide").map((slide, i) => ({
    el: slide,
    factor: speeds[i % speeds.length] - 1,
    offset: 0,
    visible: false,
  }));

  // Implement the initial scatter for resting positions
  this.applyParallax();
}

Notably, I create the factor as speed - 1, meaning a 0 factor aligns movement precisely with the timeline. This ensures offsets remain minimal. The alternating pattern of faster and slower slides ensures nearby slides don’t move in perfect unison. It’s worth mentioning: i % speeds.length allows for repeated motion patterns every six slides, so slides 1 and 7 would behave the same. That’s acceptable in this scenario since they are spaced about 2,500px apart, thus minimizing visibility overlap. However, for slides of smaller sizes or larger viewports, this could become more pronounced. To eliminate any repetition within one cycle, simply extend the array to match your total slide count.

The challenge lies in the need for a seamless transition that avoids visual jarring as slides move. By aligning the offset to zero at the defined wrap points, you prevent an obvious jump when a slide is repositioned. When implementing this, the code employs a strategy where each slide’s travel is determined by multiplying the progress by its factor. This ensures that as the items traverse from the top of the screen to the bottom, the adjustments made are not perceptible to the user at the critical moment of wrapping.

/** Implement parallax effect with visibility checks */
applyParallax(immediate = false) {
  const visibilityChanges = [];

  this.parallax.forEach((item) => {
    const rect = item.el.getBoundingClientRect();
    const loopTop = rect.top - item.offset;

    // Calculate new offset; should be zero at the wrap point
    item.offset = item.factor * (loopTop + rect.height);
    gsap.set(item.el, { y: item.offset });

    const top = loopTop + item.offset;
    const isVisible = top < window.innerHeight && top + rect.height > 0;

    if (isVisible !== item.visible) {
      item.visible = isVisible;
      visibilityChanges.push({ el: item.el, visible: isVisible, top });
    }
  });

  if (visibilityChanges.length) this.onToggle?.(visibilityChanges, immediate);
}

Calculating loopTop + rect.height allows us to determine when each slide leaves the visible viewport. This value becomes critical as it hits zero precisely when the slide exits the top of the screen, ensuring that the adjustment synchronizes flawlessly at the wrap juncture.

The stability of this movement hinges on the quick subtraction of the previous frame’s item.offset from rect.top. By doing so, we revert to a clean state, maintaining a consistent looping behavior. If not carefully managed, the offsets could stack up and lead to an undesirable effect where slides drift off-screen within seconds.

On the visibility front, this technique takes advantage of the fact that we're measuring each rectangle during every frame. This means identifying which slides have come into or exited the viewport requires minimal resource overhead. The key assessment occurs at the adjusted top position, which reflects the slide's true location on-screen post-parallax adjustment.

Rather than executing actions at this stage, we simply report the visibility state through this.onToggle?.(visibilityChanges, immediate). It’s a clean interface, which provides a concise callback containing the visibility changes. This flexibility is useful; even if no additional components are linked, the slider maintains its functionality without any disruption.

3. Reveal Functionality

Revealing each slide as it enters the viewport is straightforward: initiate a fade-in effect when a slide becomes visible and effectively reset the state once it leaves. Since we are dealing with an infinite loop scenario, ensuring each slide resets correctly is vital; otherwise, animations would play inconsistently.

constructor() {
  // Track reveal targets for each slide
  this.items = new Map();

  gsap.utils.toArray(".gallery__slide").forEach((slide) => {
    const wrapper = slide.querySelector(".gallery__img-wrapper");

    // Break captions into distinct characters
    const chars = new SplitText(slide.querySelector("span"), {
      type: "chars",
    }).chars;

    // Set the initial state to make the image and caption invisible
    gsap.set(wrapper, { autoAlpha: 0 });
    gsap.set(chars, { autoAlpha: 0 });

    this.items.set(slide, { wrapper, chars });
  });
}

Using a Map to store each slide’s visibility not only simplifies lookups later but also decouples state management from the DOM nodes themselves.

The autoAlpha property served here combines opacity handling with visibility management, which is crucial in performance-sensitive environments. This shorthand allows the browser to skip rendering fully transparent elements, hence optimizing performance in cases where most of the gallery is hidden away by default.

Once a change in visibility occurs, orchestrating the reveal is where precision comes into play:

/** Manage visibility changes for slides */
toggle(changes, immediate = false) {
  changes
    .filter(change => change.visible)
    .sort((a, b) => a.top - b.top)
    .forEach((change, i) => this.show(change.el, i * 0.12, immediate));

  changes
    .filter(change => !change.visible)
    .forEach(change => this.hide(change.el));
}

By sorting the visible changes based on their top position before staggering any animations, the reveal maintains an orderly and intentional flow. This step is crucial; in an infinite loop, the DOM order doesn’t necessarily correlate to the visual order on-screen. Thus, cascading reveals downward creates a natural viewing experience, no matter how the user scrolls.

When it’s time to reveal individual elements, it’s a two-step process, with the image fading in first, followed by its caption:

/** Fade in the image first, then characters in sequence */
show(slide, delay, immediate = false) {
  const { wrapper, chars } = this.items.get(slide);

  // Prevent entrance animation if immediate flag is set
  if (immediate) {
    gsap.set([wrapper, ...chars], { autoAlpha: 1, overwrite: true });
    return;
  }

  gsap.to(wrapper, {
    autoAlpha: 1,
    duration: 1,
    ease: "power2.out",
    delay,
    overwrite: true,
  });

  gsap.to(chars, {
    autoAlpha: 1,
    duration: 0.4,
    ease: "none",
    stagger: 0.025,
    delay: delay + 0.2,
    overwrite: true,
  });
}

Fast scrolling can cause a visible overlap of fade animations, triggering flickering as competing tweens fight for the same target. The overwrite: true command is essential; it ensures that any previous animations are immediately stopped when a new animation begins, maintaining visual integrity.

Instantly hiding elements accomplishes a similar goal:

/** Reset state for seamless replay on the next cycle */
hide(slide) {
  const { wrapper, chars } = this.items.get(slide);

  gsap.set(wrapper, { autoAlpha: 0, overwrite: true });
  gsap.set(chars, { autoAlpha: 0, overwrite: true });
}

There’s no need for off-screen elements to perform an exit animation, as they can’t be seen. Instead, they require a swift reset to ensure a clean slate for their next appearance in the continuous loop.

Final Thoughts

When you take a step back, the interplay of the three modules in this gallery design reveals a sophisticated orchestration beneath its seemingly simple functions. Each module—slider, reveal, and transition—contributes to an intricate dance that not only enhances user interaction but also maintains an elegant user experience. With the slider anchoring the scroll position and ensuring seamless transitions, the mechanism for real-time updates and navigation becomes intuitively smooth. But here’s the kicker: performance optimizations are paramount, especially as this gallery scales up. Currently, the design handles a small number of slides well, but as you increase that number, you run the risk of running into layout thrashing, which could significantly degrade the experience. Pre-measuring slide dimensions and batching updates could shield the system from potential slowdowns in larger implementations. If you’re managing a high-concept gallery, addressing these performance aspects early on will pay dividends. Accessibility is another critical area that can't be overlooked. While the current setup gets a lot right—tab indices, role attributes, and even escape functionality—the absence of keyboard scrolling and reduced motion handling are noticeable gaps. It's not just about usability but also about inclusivity. Implementing these features shouldn’t just be an afterthought; they can be straightforward enhancements that broaden your audience's engagement. Looking ahead, the potential for subtle tweaks to enhance user perception is tantalizing. Small adjustments to speeds or easing functions can dramatically change the audience's experience—a shift that’s both simple and profound. The ability to fine-tune these parameters offers developers a unique opportunity to tailor the gallery to specific themes or moods, making each display a fresh experience. In conclusion, marrying technical excellence with user empathy will define the next steps for this gallery's evolution. The fabric of engagement lies in those fine details, from performance refinements to deeper accessibility considerations. What remains clear is that the behind-the-scenes choreography is just as vital as the performance itself. So, if you're iterating on this setup, keep these insights front and center—the nuances matter.
Source: Surya Aditya · tympanus.net

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

Building an Infinite GSAP Scroll Gallery with Parallax an...