Going "Meta GSAP": The Quest for "Perfect" Infinite Scrolling
Tags: javascript, css, tutorial, webdev
Published: 2021-10-11 19:40:48

I'm not sure how this one came about. But, it's a story. This article is more about grokking a concept, one that's going to help you think about your animations in a different way. It so happens that this particular example features infinite scrolling — specifically the "perfect" infinite scroll for a deck of cards without duplicating any of them.

Why am I here? Well, this all started from a tweet. A tweet that got me thinking about layouts and side-scrolling content.

{% twitter 1329534673987506184 %}

I took that concept and used it on [my site](https://jhey.dev). And it's still there in action at the time of writing.

{% codepen https://codepen.io/jh3y/pen/WNxBLbB %}

Then I got to thinking more about gallery views and side-scrolling concepts. We hopped on a [livestream](https://twitch.tv/jh3yy) and decided to try and make something like the old Apple "Cover Flow" pattern. [Remember it?](https://www.google.com/search?q=apple+cover+flow)

My first thoughts for making this assumed I'd make this so it works without JavaScript, as it does in the demo above, in a way that uses "progressive enhancement." I grabbed [Greensock](https://greensock.com/gsap/) and [ScrollTrigger](https://greensock.com/scrolltrigger/), and off we went. I came away from that work pretty disappointed. I had something but couldn't quite get infinite scrolling to work how the way I wanted. The "Next” and “Previous" buttons didn't want to play ball. You can see it here, and it requires horizontal scrolling.

{% codepen https://codepen.io/jh3y/pen/9087b98b088d025b16fc50aff45ba9f1 %}

So I opened up a [new thread](https://greensock.com/forums/topic/26922-missing-something-w-horizontal-scrolltrigger-%F0%9F%98%85/) on the Greensock forum. Little did I know I was about to open myself up to some serious learning! We solved the issue with the buttons. But, being me, I had to ask whether something else was possible. Was there a "clean" way to do infinite scrolling? I'd tried something on stream but had no luck. I was curious. I'd tried a technique like that used in this pen which I created for the ScrollTrigger release.

{% codepen https://codepen.io/jh3y/pen/RwraKYZ %}

The [initial answer](https://greensock.com/forums/topic/26922-missing-something-w-horizontal-scrolltrigger-%F0%9F%98%85/?do=findComment&comment=131318&_rid=80771) was that it is kinda tricky to do:

> The hard part about infinite things on scroll is that the scroll bar is limited while the effect that you're wanting is not. So you have to either loop the scroll position like [this demo](https://cdpn.io/GreenSock/pen/rNOebyo) (found in the [ScrollTrigger demos section](https://greensock.com/st-demos/)) or hook directly into the scroll-related navigation events (like the wheel event) instead of actually using the actual scroll position.

I figured that was the case and was happy to leave it "as-is." A couple of days passed and Jack [dropped a reply](https://greensock.com/forums/topic/26922-missing-something-w-horizontal-scrolltrigger-%F0%9F%98%85/?do=findComment&comment=131832&_rid=80771) that kinda blew my mind when I started digging into it. And now, after a bunch of going through it, I'm here to share the technique with you.

## Animate anything

One thing that is often overlooked with GSAP, is that you can animate almost anything with it. This is often because visual things are what spring to mind when thinking about animation — the actual physical movement of something. Our first thought isn't about taking that process to a meta-level and animating from a step back.

But, think about animation work on a larger scale and then break it down into layers. For example, you play a cartoon. The cartoon is a collection of compositions. Each composition is a scene. And then you have the power to scrub through that collection of compositions with a remote, whether it’s on YouTube, using your TV remote, or whatever. There are almost three levels to what is happening.

And this is the trick we need for creating **different types of infinite loops**. This is the main concept right here. We animate the play head position of a timeline with a timeline. And then we can scrub that timeline with our scroll position.

Don't worry if that sounds confusing. We're going to break it down.

## Going “meta”

Let's start with an example. We're going to create a tween that moves some boxes from left to right. Here it is.

{% codepen https://codepen.io/jh3y/pen/qBqxKGd %}

Ten boxes that keep going left to right. That's quite straightforward with Greensock. Here, we use `fromTo` and `repeat` to keep the animation going. But, we have a gap at the start of each iteration. We’re also using `stagger` to space out the movement and that’s something that will play an important role as we continue.

```javascript
gsap.fromTo('.box', {
  xPercent: 100
}, {
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  repeat: -1,
  ease: 'none',
})
```

Now comes the fun part. Let's pause the tween and assign it to a variable. Then let’s create a tween that plays it. We can do this by tweening the `totalTime` of the tween, which allows us to get or set the tween’s playhead tween, while considering repeats and repeat delays.

```javascript
const SHIFT = gsap.fromTo('.box', {
  xPercent: 100
}, {
  paused: true,
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  repeat: -1,
  ease: 'none',
})

const DURATION = SHIFT.duration()

gsap.to(SHIFT, {
  totalTime: DURATION,
  repeat: -1,
  duration: DURATION,
  ease: 'none',
})
```

This is our first “meta” tween. It looks [exactly the same](https://codepen.io/jh3y/pen/MWbQBYL) but we’re adding another level of control. We can change things on this layer without affecting the original layer. For example, we could change the tween `ease` to `power4.in`. This completely changes the animation but without affecting the underlying animation. We're kinda safeguarding ourselves with a fallback.

{% codepen https://codepen.io/jh3y/pen/ZEBrjpd %}

Not only that, we might choose to repeat only a certain part of the timeline. We could do that with another `fromTo`, like this:

{% codepen https://codepen.io/jh3y/pen/eYBVjQp %}

The code for that would be something like this.

```javascript
gsap.fromTo(SHIFT, {
  totalTime: 2,
}, {
  totalTime: DURATION - 1,
  repeat: -1,
  duration: DURATION,
  ease: 'none'
})
```

Do you see where this is going? Watch that tween. Although it keeps looping, the numbers flip on each repeat. But, the boxes are in the correct position.

## Achieving the “perfect” loop

If we go back to our original example, there’s a noticeable gap between each repetition.

{% codepen https://codepen.io/jh3y/pen/MWbQBYL %}

Here comes the trick. The part that unlocks everything. We need to build a perfect loop.

Let's start by repeating the shift three times. It's equal to using `repeat: 3`. Notice how we’ve removed `repeat: -1` from the tween.

```javascript
const getShift = () => gsap.fromTo('.box', {
  xPercent: 100
}, {
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  ease: 'none',
})

const LOOP = gsap.timeline()
  .add(getShift())
  .add(getShift())
  .add(getShift())
```

We’ve turned the initial tween into a function that returns the tween and we add it to a new timeline three times. And this gives us the following.

{% codepen https://codepen.io/jh3y/pen/zYoRgEd %}

OK. But, there's still a gap. Now we can bring [in the `position` parameter](https://greensock.com/position-parameter/) for adding and positioning those tweens. We want it to be seamless. That means inserting each each set of tweens before the previous one ends. That’s a value based on the `stagger` and the amount of elements.

```javascript
const stagger = 0.5 // Used in our shifting tween
const BOXES = gsap.utils.toArray('.box')
const LOOP = gsap.timeline({
  repeat: -1
})
  .add(getShift(), 0)
  .add(getShift(), BOXES.length * stagger)
  .add(getShift(), BOXES.length * stagger * 2)
```

If we update our timeline to repeat and watch it (while adjusting the `stagger` to see how it affects things)…

{% codepen https://codepen.io/jh3y/pen/wvoyVxB %}

You'll notice that there's a window in the middle there that creates a "seamless" loop. Recall those skills from earlier where we manipulated time? That's what we need to do here: loop the window of time where the loop is "seamless."

We could try tweening the `totalTime` through that window of the loop.

```javascript
const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
})
.add(getShift(), 0)
.add(getShift(), BOXES.length * stagger)
.add(getShift(), BOXES.length * stagger * 2)

gsap.fromTo(LOOP, {
  totalTime: 4.75,
},
{
  totalTime: '+=5', // HIGHLIGHT
  duration: 10,
  ease: 'none',
  repeat: -1,
})
```

Here, we’re saying tween the `totalTime` from `4.75` and add the length of a cycle to that. The length of a cycle is `5`. And that’s the middle window of the timeline. We can use GSAP’s nifty `+=` to do that, which gives us this:

{% codepen https://codepen.io/jh3y/pen/oNYEKVQ %}

Take a moment to digest what's happening there. This could be the trickiest part to wrap your head around. We're calculating windows of time in our timeline. It’s kinda hard to visualise but I’ve had a go.

This is a demo of a watch that takes 12 seconds for the hands go round once. It's looped infinitely with `repeat: -1` and then we're using `fromTo` to animate a specific time window with a given duration. If you, reduce the time window to say `2` and `6`, then change the duration to `1`, the hands will go from 2 o'clock to 6 o'clock on repeat. But, we never changed the underlying animation.

Try configuring the values to see how it affects things.

{% codepen https://codepen.io/jh3y/pen/yLgOgpq %}

At this point, it’s a good idea to put together a formula for our window position. We could also use a variable for the duration it takes for each box to transition.

```javascript
const DURATION = 1
const CYCLE_DURATION = BOXES.length * STAGGER
const START_TIME = CYCLE_DURATION + (DURATION * 0.5)
const END_TIME = START_TIME + CYCLE_DURATION
```

Instead of using three stacked timelines, we could loop over our elements three times where we get the benefit of not needing to calculate the positions. Visualizing this as three stacked timelines is a neat way to grok the concept, though, and a nice way to help understand the main idea.

Let’s change our implementation to create one big timeline from the start.

```javascript
const STAGGER = 0.5
const BOXES = gsap.utils.toArray('.box')

const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
})

const SHIFTS = [...BOXES, ...BOXES, ...BOXES]

SHIFTS.forEach((BOX, index) => {
  LOOP.fromTo(BOX, {
    xPercent: 100
  }, {
    xPercent: -200,
    duration: 1,
    ease: 'none',
  }, index * STAGGER)
})
```

This is easier to put together and gives us the same window. But, we don't need to think about math. Now we loop through three sets of the boxes and position each animation according to the stagger.

{% codepen https://codepen.io/jh3y/pen/YzNPVJZ %}

How might that look if we adjust the stagger? It will squish the boxes closer together.

{% codepen https://codepen.io/jh3y/pen/XWpJgRK %}

But, it’s broken the window because now the `totalTime` is out. We need to recalculate the window. Now’s a good time to plug in the formula we calculated earlier.

```javascript
const DURATION = 1
const CYCLE_DURATION = STAGGER * BOXES.length
const START_TIME = CYCLE_DURATION + (DURATION * 0.5)
const END_TIME = START_TIME + CYCLE_DURATION

gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: END_TIME,
  duration: 10,
  ease: 'none',
  repeat: -1,
})
```

Fixed!

{% codepen https://codepen.io/jh3y/pen/YzNPrKX %}

We could even introduce an “offset” if we wanted to change the starting position.

```javascript
const STAGGER = 0.5
const OFFSET = 5 * STAGGER
const START_TIME = (CYCLE_DURATION + (STAGGER * 0.5)) + OFFSET
```

Now our window starts from a different position.

{% codepen https://codepen.io/jh3y/pen/VwPYMBm %}

But still, this isn’t great as it gives us these awkward stacks at each end. To get rid of that effect, we need to think about a "physical" window for our boxes. Or think about how they enter and exit the scene.

We’re going to use `document.body` as the window for our example. Let's update the box tweens to be individual timelines where the boxes scale up on enter and down on exit. We can use `yoyo` and `repeat: 1` to achieve entering and exiting.

```javascript
SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
      }, 0
    )
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})
```

Why are we using a timeline duration of `1`? It makes things easier to follow. We know the time is `0.5` when the box is at the midpoint. It's worth noting that easing won't have the effect we usually think of here. In fact, easing will actually play a part in how the boxes position themselves. For example, an `ease-in` would bunch the boxes up on the right before they move across.

The code above gives us this.

{% codepen https://codepen.io/jh3y/pen/abpzVVJ %}

Almost. But, our boxes disappear for a time in the middle. To fix this, let’s introduce the `immediateRender` property. It acts like `animation-fill-mode: none` in CSS. We’re telling GSAP that we don’t want to retain or pre-record any styles that are being set on a box.

```javascript
SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
        immediateRender: false, // HIGHLIGHT
      }, 0
    )
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        zIndex: BOXES.length + 1,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
        immediateRender: false, // HIGHLIGHT
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})
```

That small change fixes things for us! Note how we’ve also included `z-index: BOXES.length`. That should safeguard us against any `z-index` issues.

{% codepen https://codepen.io/jh3y/pen/JjEoMNa %}

There we have it! Our first infinite seamless loop. No duplicate elements and perfect continuation. We’re bending time! Pat yourself on the back if you’ve gotten this far! 🎉

If we want to see more boxes at once, we can tinker with the timing, stagger, and ease. Here, we have a `STAGGER` of `0.2` and we’ve also introduced `opacity` into the mix.

{% codepen https://codepen.io/jh3y/pen/WNRbMRM %}

The key part here is that we can make use of `repeatDelay` so that the `opacity` transition is quicker than the scale. Fade in over 0.25 seconds. Wait 0.5 seconds. Fade back out over 0.25 seconds.

```javascript
.fromTo(
  BOX, {
    opacity: 0, // HIGHLIGHT
  }, {
    opacity: 1, // HIGHLIGHT
    duration: 0.25,
    repeat: 1,
    repeatDelay: 0.5, // HIGHLIGHT
    immediateRender: false,
    ease: 'none',
    yoyo: true,
  }, 0)
```

Cool! We could do whatever we want with those in and out transitions. The main thing here is that we have our window of time that gives us the infinite loop.

## Hooking this up to scroll

Now that we have a seamless loop, let’s attach it to scroll. For this we can use GSAP’s ScrollTrigger. This requires an extra tween to scrub our looping window. Note how we’ve set the loop to be `paused` now, too.

```javascript
const LOOP_HEAD = gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: END_TIME,
  duration: 10,
  ease: 'none',
  repeat: -1,
  paused: true,
})

const SCRUB = gsap.to(LOOP_HEAD, {
  totalTime: 0,
  paused: true,
  duration: 1,
  ease: 'none',
})
```

The trick here is to use `ScrollTrigger` to scrub the play head of the loop by updating the `totalTime` of `SCRUB`. There are various ways we could set up this scroll. We could have it horizontal or bound to a container. But, what we're going to do is wrap our boxes in a `.boxes` element and pin that to the viewport. (This fixes its position in the viewport.) We'll also stick with vertical scrolling. Check the demo to see the styling for `.boxes` which sets things to the size of the viewport.

```javascript
import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)

ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    SCRUB.vars.totalTime = LOOP_HEAD.duration() * self.progress
    SCRUB.invalidate().restart()
  }
})
```

The important part is inside `onUpdate`. That’s where we set the `totalTime` of the tween based on the scroll progress. The `invalidate` call flushes any internally recorded positions for the scrub. The `restart` then sets the position to the new `totalTime` we set.

Try it out! We can go back and forth in the timeline and update the position.

{% codepen https://codepen.io/jh3y/pen/QWdwVrd %}

How cool is that? We can scroll to scrub a timeline that scrubs a timeline that is a window of a timeline. Digest that for a second because that's what's happening here.

## Time travel for infinite scrolling

Up to now, we've been manipulating time. Now we're going to time travel!

To do this, we're going to use some other GSAP utilities and we're no longer going to scrub the `totalTime` of `LOOP_HEAD`. Instead, we're going to update it via proxy. This is another great example of going “meta” GSAP.

Let’s start with a proxy object that marks the playhead position.

```javascript
const PLAYHEAD = { position: 0 }
```

Now we can update our `SCRUB` to update the `position`. At the same time, we can use GSAP’s `wrap` utility, which wraps the `position` value around the `LOOP_HEAD` duration. For example, if the duration is `10` and we provide the value `11`, we will get back `1`.

```javascript
const POSITION_WRAP = gsap.utils.wrap(0, LOOP_HEAD.duration())

const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 1,
  ease: 'none',
})
```

Last, but not least, we need to revise ScrollTrigger so it updates the correct variable on the `SCRUB`. That’s `position`, instead of `totalTime`.

```javascript
ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    SCRUB.vars.position = LOOP_HEAD.duration() * self.progress
    SCRUB.invalidate().restart()
  }
})
```

At this point we’ve switched to a proxy and we won’t see any changes.

{% codepen https://codepen.io/jh3y/pen/LYxEXej %}

We want an infinite loop when we scroll. Our first thought might be to scroll to the start when we complete scroll progress. And it would do exactly that, scroll back. Although that's what we want to do, we don't want the playhead to scrub backwards. This is where `totalTime` comes in. Remember? It gets or sets the position of the playhead according to the `totalDuration` which includes any repeats and repeat delays.

For example, say the duration of the loop head was `5` and we got there, we won't scrub back to 0. Instead, we will keep scrubbing the loop head to `10`. If we keep going, it'll go to `15`, and so on. Meanwhile, we'll keep track of an `iteration` variable because that tells us where we are in the scrub. We'll also make sure that we only update `iteration` when we hit the progress thresholds.

Let’s start with an `iteration` variable:

```javascript
let iteration = 0
```

Now let’s update our ScrollTrigger implementation:

```javascript
const TRIGGER = ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    const SCROLL = self.scroll()
    if (SCROLL > self.end - 1) {
      // Go forwards in time
      WRAP(1, 1)
    } else if (SCROLL < 1 && self.direction < 0) {
      // Go backwards in time
      WRAP(-1, self.end - 1)
    } else {
      SCRUB.vars.position = (iteration + self.progress) * LOOP_HEAD.duration() // HIGIHLIGHT
      SCRUB.invalidate().restart()
    }
  }
})
```

Notice how we're now factoring `iteration` into the `position` calculation. Remember that this gets wrapped with the scrubber. We're also detecting when we hit the limits of our scroll, and that's the point where we `WRAP`. This function sets the appropriate `iteration` value and sets the new scroll position.

```javascript
const WRAP = (iterationDelta, scrollTo) => {
  iteration += iterationDelta
  TRIGGER.scroll(scrollTo)
  TRIGGER.update()
}
```

We have infinite scrolling! If you have one of those fancy mice with the scroll wheel that you can let loose on, give it a go! It’s fun!

{% codepen https://codepen.io/jh3y/pen/QWdwzMK %}

Here’s a demo that displays the current `iteration` and `progress`:

{% codepen https://codepen.io/jh3y/pen/XWpJOaG %}
## Scroll snapping

We're there. But, there are always ”nice to haves" when working on a feature like this. Let's start with scroll snapping. GSAP makes this easy, as we can use `gsap.utils.snap` without any other dependencies. That handles snapping to a time when we provide the points. We declare the step between `0` and `1` and we have `10` boxes in our example. That means a snap of `0.1` would work for us.

```javascript
const SNAP = gsap.utils.snap(1 / BOXES.length)
```

And that returns a function we can use to snap our `position` value.

We only want to snap once the scroll has ended. For that, we can use an event listener on ScrollTrigger. When the scroll ends, we are going to scroll to a certain `position`.

```javascript
ScrollTrigger.addEventListener('scrollEnd', () => {
  scrollToPosition(SCRUB.vars.position)
})
```

And here’s `scrollToPosition`:

```javascript
const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS =
    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  TRIGGER.scroll(SCROLL)
}
```

What are we doing here?

1. Calculating the point in time to snap to
2. Calculating the current progress. Let’s say the `LOOP_HEAD.duration()` is `1` and we’ve snapped to `2.5`. That gives us a progress of `0.5` resulting in an `iteration` of `2`, where `2.5 - 1 * 2 / 1 === 0.5` . We calculate the progress so that it’s always between `1` and `0`.
3. Calculating the scroll destination. This is a fraction of the distance our ScrollTrigger can cover. In our example, we’ve set a distance of `2000` and we want a fraction of that. We create a new function `progressToScroll` to calculate it.

```javascript
const progressToScroll = progress =>
  gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)
```

This function takes the progress value and maps it to the largest scroll distance. But we use a clamp to make sure the value can never be `0` or `2000`. This is important. We are safeguarding against snapping to these values as it would put us in an infinite loop.

There is a bit to take in there. Check out this demo that shows the updated values on each snap.

{% codepen https://codepen.io/jh3y/pen/vYgEPzr %}

Why are things a lot snappier? The scrubbing duration and ease have been altered. A smaller duration and punchier ease give us the snap.

```javascript
const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 0.25,
  ease: 'power3',
})
```

But, if you played with that demo, you'll notice there's an issue. Sometimes when we wrap around inside the snap, the playhead jumps about. We need to account for that by making sure we wrap when we snap — but, only when it's necessary.

```javascript
const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS =
    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  if (PROGRESS >= 1 || PROGRESS < 0) return WRAP(Math.floor(PROGRESS), SCROLL)
  TRIGGER.scroll(SCROLL)
}
```

And now we have infinite scrolling with snapping!

{% codepen https://codepen.io/jh3y/pen/OJWVwLO %}

## What next?

We've completed the groundwork for a solid infinite scroller. We can leverage that to add things, like controls or keyboard functionality. For example, this could be a way to hook up "Next" and "Previous" buttons and keyboard controls. All we have to do is manipulate time, right?

```javascript
const NEXT = () => scrollToPosition(SCRUB.vars.position - (1 / BOXES.length))
const PREV = () => scrollToPosition(SCRUB.vars.position + (1 / BOXES.length))

// Left and Right arrow plus A and D
document.addEventListener('keydown', event => {
  if (event.keyCode === 37 || event.keyCode === 65) NEXT()
  if (event.keyCode === 39 || event.keyCode === 68) PREV()
})

document.querySelector('.next').addEventListener('click', NEXT)
document.querySelector('.prev').addEventListener('click', PREV)
```

That could give us something like this.

{% codepen https://codepen.io/jh3y/pen/GRrJYpJ %}

We can leverage our `scrollToPosition` function and bump the value as we need.

## That’s it!

See that? GSAP can animate more than elements! Here, we bent and manipulated time to create an almost perfect infinite slider. No duplicate elements, no mess, and good flexibility.

Let’s recap what we covered:

- We can animate an animation. 🤯
- We can think about timing as a positioning tools when we manipulate time.
- How to use ScrollTrigger to scrub an animation via proxy.
- How to use some of GSAP’s awesome utilities to handle logic for us.

You can now manipulate time! 😅

That concept of going "meta” GSAP opens up a variety of possibilities. What else could you animate? Audio? Video? As for the ”Cover Flow” demo, here’s where that went!

{% codepen https://codepen.io/jh3y/pen/WNRvqJP %}

**Stay Awesome! ʕ •ᴥ•ʔ**