Jump to content
Search Community

ScrollTrigger + React: trouble when pinnings multiple elements on the same page

Théophile Avoyne test
Moderator Tag

Go to solution Solved by GreenSock,

Recommended Posts

Hello everyone,

 

This is a reformulation of a topic that I created a few days ago. I am creating a new one here because I think it is more a ScrollTrigger+React-related problem and that it might benefit from being referenced as such.

 

Context

There are two consecutive "sections" that are both a 100vw/100vh. Each section is a React component (in the CodeSandbox below, they're called respectively WorkOverview and HomeAbout). They both get pinned one after the other.

 

Problem

The second element gets pinned too early, exactly as if the padding of the first section's .pin-spacer wasn't taken into account. The weirdest thing is that it doesn't happen all the time (but must of the time). Please note that (1) the ScrollTriggers are created in the order they happen on the page and that (2) it is not caused by any asynchronously-loaded content on what the sections' sizing might rely (images are inside a pre-sized container). Here's a video that illustrate the problem:

 

 

Here's the CodeSandbox link https://codesandbox.io/s/clever-rhodes-16ic1. Note: if you don't see the problem, refresh the page 1 or 2 times.

 

Thanks in advance for you precious help!

Link to comment
Share on other sites

I'd check to make sure your kill function for ScrollTrigger is working as intended.

If you turn markers on, you can usually see if they are being added/duplicated and not removed, otherwise, try to console.log the ScrollTrigger instance after kill it in your garbage cleanup.

If this is the case, you may need to give your trigger an ID then kill it like:

ScrollTrigger.getById("myID").kill();
  • Like 3
Link to comment
Share on other sites

3 hours ago, elegantseagulls said:

I'd check to make sure your kill function for ScrollTrigger is working as intended.

 

Thanks @elegantseagulls. I'm not sure why you think the problem has anything to do with this. The ScrollTrigger never has to be killed (and never is) since there's only one page.

 

3 hours ago, ZachSaucier said:

If you resize the page it fixes itself. So it has something to do with creation order.

 

Thanks @ZachSaucier. Believe me it hasn't. I added a console.log() after each ScrollTrigger in the CodeSandbox, so that you can see by yourself. But I agree, it's the only thing that could have explained what's happening (except for the part that sometimes it works like a charm). There's a race condition, but it seems not to be coming from my code!

Link to comment
Share on other sites

  • Solution

@Théophile Avoyne Thanks so much for putting together that minimal demo. Super helpful. I wish all users were as considerate.

 

Short answer:

Either call refresh() on the timeline's ScrollTrigger instance after you're done adding things to it (and before creating ScrollTriggers for things further down on the page) or just call ScrollTrigger.refresh() after you create all your ScrollTriggers.

See the Pen 8ff7ed837db8924f3745cb5b2600774e?editors=0010 by GreenSock (@GreenSock) on CodePen

 

Explanation:

One of the more complex pieces of building a tool like ScrollTrigger is factoring in the cascading effects of pinning and all the animations. Your demo exposes a very tricky scenario that isn't so much a bug as a logical impossibility, but it's easily worked around by the solution I offered above. 

 

Timelines are a unique beast because when you create one with a ScrollTrigger like this...

gsap.timeline({ scrollTrigger: {...}});

...no animations have been added  yet (and those might affect the start/end positions), so ScrollTrigger waits for one tick before calling its refresh(). That's the method that causes it to calculate its start and end positions. There's no way for it to automatically know when you've added the last animation to the timeline, hence the need for the one-tick delay. However, if the timeline's ScrollTrigger performs pinning, it could affect all the ScrollTriggers further down on the page. That's the case in your demo.

 

For example, if it pins an element for 500px (assuming pinSpacing isn't false), all the subsequent ScrollTriggers would need their start/end values increased by 500px. If you then create ScrollTriggers that aren't in timelines, their start/end values will be calculated right away (before the previous timeline's scrollTrigger calculates its start/end...because of the 1-tick delay), so they won't factor in the pinning offset. Why? Because when they try to look up the chain to factor in how long pinning occurs in previous ScrollTriggers, that value isn't calculated yet for that timeline-based one.

 

This can easily be solved by either manually calling refresh() on the timeline after you're done adding all the animations to it (and BEFORE you create subsequent ScrollTriggers further down the page) or simply call the static ScrollTrigger.refresh() method after you're done creating everything. 

 

Note: you can get the ScrollTrigger instance associated with a particular animation via its scrollTrigger property:

// create a timeline with a ScrollTrigger
const tl = gsap.timeline({ scrollTrigger: {...}});

// after adding all animations to that timeline, we can manually force it to calculate its start/end (on just that instance):
tl.scrollTrigger.refresh();

// or cause ALL ScrollTrigger instances to refresh using the static method:
ScrollTrigger.refresh();

So basically, in your demo you created a timeline-based ScrollTrigger for the red #section-1 which waits for 1 tick to figure out its start/end positions but you then immediately created another ScrollTrigger for the blue #section-2 and since it's not timeline-based, it immediately calculated its start/end and those didn't factor in the timeline's ScrollTrigger pinning.  And interestingly, you were running your code in a setTimeout() so when ScrollTrigger did its initial refresh() call when the page finishes loading, your stuff wasn't created yet. 

 

Does that clear things up? 

 

After further consideration, I decided to add some code to the next release of ScrollTrigger to sense this condition better and if a subsequent ScrollTrigger finds an un-refreshed ScrollTrigger before it, it'll force a refresh() call on that instance at that moment. There's still a risk that the user hasn't finished adding animations to that timeline which could affect things, but I think that's an even more rare scenario. You can preview that at https://assets.codepen.io/16327/ScrollTrigger.min.js

 

And here's a fork of your demo with that preview in place (you may need to clear your cache): 

See the Pen 373c2f96012b24007507967c38f2d4dd?editors=0010 by GreenSock (@GreenSock) on CodePen

  • Like 4
Link to comment
Share on other sites

Hi Jack,

 

Thanks a lot for your in-depth explanation. It's crystal clear. The two alternatives you provided indeed fix the issue (both in the context of the demo and the website I'm working on).

 

7 hours ago, GreenSock said:

I decided to add some code to the next release of ScrollTrigger to sense this condition better and if a subsequent ScrollTrigger finds an un-refreshed ScrollTrigger before it, it'll force a refresh() call on that instance at that moment.

This sounds like a great fix.

 

By the way, thanks a lot for everything you guys are doing. GreenSock products are GREAT, I really enjoy using them everyday 👏

 

 

  • Like 3
Link to comment
Share on other sites

  • 4 months later...

Hi @Théophile Avoyne.
Could you please provide your final piece of code in react that is related to animation?
I have just one ScrollTrigger instance that get initialized at the wrong time similar to what's happening on your first video and calling refresh doesn't help.
The only thing which helps is setTimeout

P.S resizing the window fixes the problem. Start marker moves to the right position on the page

  • Like 1
Link to comment
Share on other sites

  • 7 months later...

Hi Théophile Avoyne,

could you share your solution in the react app? I am facing the same exact problem in a Next.js application, in which I am initializing 3 scroll trigger timelines in separate components. I tried everything mentioned here
and some more solutions, nothing worked. I can't even replicate the error outside react. Refresh in particular doesn't seem to fire at all.

The only way that made scroll trigger recalculate its height, is this:

 

setTimeout(() => {
  tl.scrollTrigger.disable();
  tl.scrollTrigger.enable();
}, 2000)

and it is an horrible solution. For starter the use of set timeout is not reliable, for that reason is set at 2seconds. And then, I have to switch on and off the scroll trigger, refresh in the same position doesn't work at all.

Link to comment
Share on other sites

  • 1 month later...

I'm running into a similar issue to what Théophile did, as I have multiple react components with scroll triggers and pins on the same page. The refresh solution above seems pretty straightforward, but for some reason the scrollTrigger object is undefined, so tl.scrollTrigger.refresh() throws an error. Any idea why that might be? I'm running version 3.10.4 of the greensock library if something changed to impact this since last year. Below is the relevant logic I'm using currently in the react component to create the animation (images are just divs inside that top level div).

const ref = useRef<HTMLDivElement>(null);

const animateImage = (isStart: boolean, isEnd: boolean, tl: Timeline, image: Element) => {
  tl
    .fromTo(image, { y: 500, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: .5, delay: isStart ? 0 : -.4 });
  if (!isEnd) {
    tl.to(image, { duration: .3, autoAlpha: 0 });
  }
}

useLayoutEffect(() => {
  const element = ref.current;
  if (element) {
    const images = element.querySelectorAll('.imageContainer > div');
    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: element,
        pin: true,
        scrub: true,
        start: 'top top',
        end: '+=' + (images.length * 2 * element.clientHeight)
      }
    });
    images.forEach((image, index) => {
      animateImage(index === 0, index === images.length - 1, tl, image)
    });
    console.log(tl.scrollTrigger) //scrollTrigger is undefined when I check with this
  }
})

return (
  <div ref={ref}>
    ...
  </div>
)

I can put together demo of the issue if needed, but I want to check if there is a quick explanation for why tl.scrollTrigger would be undefined before doing that.

Link to comment
Share on other sites

I don't notice anything obvious, @Bonsai. We'd really need to see a minimal demo to offer a decent answer. Here's a CodeSandbox starter template: https://codesandbox.io/s/gsap-react-starter-ut42t

 

I'm not a React guy at all, but does the useLayoutEffect() need an empty Array at the end so that it doesn't get called on every render? 🤷‍♂️

Link to comment
Share on other sites

I'm fairly new to react as well, but I'm pretty sure the empty array isn't needed, as that property can be null/undefined for useEffect and useLayoutEffect. It's apparently used to pass in dependencies, which is likely used to determine execution order. As for why I'm using useLayoutEffect instead of useEffect, the former runs synchronously, so it will delay rendering until the timeline is initialized, which avoids a brief moment of out of position elements due to my use of "fromTo."

 

Fortunately, I was also able to figure out my issue while tinkering on the demo. I was missing the "pinSpacing: true" in the scrollTrigger configuration, so it was scrolling past the area before the animation finished. tl.scrollTrigger is still undefined for some reason, but it doesn't appear to be causing any issues in my build. Don't know why it's undefined, as it did have a value while I was tinkering with a demo to show the issue. Might be some weirdness caused by rendering the page with the nextjs framework.

Link to comment
Share on other sites

You will definitely into issues if you don't add that empty array on the end. It's going to run everything inside that function every time renders, creating a bunch of potential issues. Also, I would suggest pinning an inner element as pinning the root element in a component can create issues.

 

And if you haven't already, please check out our React guide.

 

 

Link to comment
Share on other sites

Good to know about why you recommend adding the "[]." I'm not sure this will be an issue in my case, as it only renders once on startup so far in my testing, but better safe than sorry. I've been avoiding logic that would lead to additional renders lower in the component tree because I'm using nextjs pre rendering, so markup that doesn't match up with the pre rendered version will load initially out of position.

 

As for the pinning, I haven't run into issues so far with it in this case, though I agree that it sometimes can cause problems due to the markup changes it has to make.

Link to comment
Share on other sites

  • 2 months later...

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...