Jump to content
Search Community

React - Multiple pinned elements with Scroll Trigger

seanom test
Moderator Tag

Recommended Posts

Hello again, I've spent some time this week working with Scroll trigger and now image sequences. But run into some issues that I'm hoping somebody can help out with.

 

The issue I have is getting scrollTrigger to work correctly with canvas when pinning the element. 

On it's own I can get the scroll trigger to scrub the image sequence, and pin the section. 

However when I add it to another page with other elements I experience issues with the other elements. 

 

My assumption is that the elements are being triggered in the incorrect order so in some cases the first pin overlaps the second pin.

 

When scrolled up and down the Canvas flips between transitions and positioned once it hits the end of its animation.

 

My goal:

  • Add multiple pinned sections on the page (from what I've read I might be able to use observer for this instead of scrollTrigger)
  • Don't overlap the pinned sections.
    •  
  • Refresh or reposition the other sections.
  • Add back in the MatchMedia queries, so this only works on desktop devices (I'm able to do this part)

 

Unsure about

  • If I need to useLayoutEffect instead of useEffect
  • move the bulk of the code out of the useEffect and pass in the relevant ones as deps 

 

The specific file I'm having issues with is the HeroScroll.jsx linked below. I've tried to take use the example from Airpods demos in the forums and update this to work in React.

https://codesandbox.io/s/scroll-trigger-react-mulitiple-pins-b0jchy?file=/src/components/HeroScroll.jsx

I feel I'm missing something major here but struggling to find the correct way to implement this.

 

It maybe that I can use the observer here instead of a timeline, and assuming that can be used with matchMedia then I can give that a go.

Thank you

Sean

Link to comment
Share on other sites

I think I'm struggling with the pinning aspect here, (along with some potential react based mistakes).

 

If I set the canvas to absolute it loads the canvas in the correct position but appears to loads multiple instances of the scroller. 

With that it makes me assume that I'm initialising the GSAP multiple times. (I'm in strict mode so this would make sense).

 

Some questions that I'm hoping will lead me down the correct path. 

  • Does it matter what position the pinned element has?, I'm assuming the spacer will be based off the height of the element being pinned not it's children. 
  • Is it possible to pin an element that has fixed/absolute positioning if it's provided and aspect ratio?
  • For react is it better to use, useLayoutEffect over useEffect to implement gsap?
  • Will GSAP timelines in different components be aware of each other?
    • If not is it possible I'm firing the timelines in the incorrect sequence?

 

Link to comment
Share on other sites

Perhaps the problem is that React 18 runs in "strict" mode locally by default which causes your useEffect() to get called TWICE! Very annoying. It has caused a lot of headaches for a lot of people outside the GSAP community too.

 

.from() tweens use the CURRENT value as the destination and it renders immediately the value you set in the tween, so when it's called the first time it'd work great but if you call it twice, it ends up animating from the from value (no animation). It's not a GSAP bug - it's a logic thing.

 

For example, let's say el.x is 0 and you do this: 

useEffect(() => {
  // what happens if this gets called twice?
  gsap.from(el, {x: 100})
}, []);

 

The first time makes el.x jump immediately to 100 and start animating backwards toward the current value which is 0 (so 100 --> 0). But the second time, it would jump to 100 (same) and animate back to the current value which is now 100 (100 --> 100)!  See the issue?

 

In GSAP 3.11, we introduced a new gsap.context() feature that solves all of this for you. All you need to do is wrap your code in a context call, and then return a cleanup function that reverts things: 

// typically it's best to useLayoutEffect() instead of useEffect() to have React render the initial state properly from the very start.
useLayoutEffect(() => {
  let ctx = gsap.context(() => {
    // all your GSAP animation code here
  });
  return () => ctx.revert(); // <- cleanup!
}, []);

 

One of the React team members chimed in here if you'd like more background.

 

We strongly recommend reading the React article at https://greensock.com/react

 

Happy tweening!

  • Thanks 1
Link to comment
Share on other sites

7 hours ago, seanom said:

Does it matter what position the pinned element has?, I'm assuming the spacer will be based off the height of the element being pinned not it's children. 

Elements with fixed position are taken out of document flow as well as elements with absolute position (unless the absolute element resides inside an element with relative position). Pin spacing is something that involves a few things, that's why there is a specific section for that in the ScrollTrigger Docs (scroll past the config options below the middle of the page where you'll find How does pinning work under the hood?)

https://greensock.com/docs/v3/Plugins/ScrollTrigger

 

7 hours ago, seanom said:

For react is it better to use, useLayoutEffect over useEffect to implement gsap?

That will depend on your setup and the level of experience you have with React. For starting is better to stick with useLayoutEffect and use useEffect in some specific cases (for example if you need to create a GSAP context instance and add animations to it based on state updates, but I don't want to create confusion here, just remember always start simple and then add complexity to your apps as you are scaffolding them). Also when working with Next is always a good idea to use this little bit by @OSUblake:

https://greensock.com/react-advanced#useIsomorphicLayoutEffect

 

7 hours ago, seanom said:

Will GSAP timelines in different components be aware of each other?

  • If not is it possible I'm firing the timelines in the incorrect sequence?

I'd try to avoid creating too complex scenarios in React (and any other component based UI library for that matter) in this regard. Sure you could create a master timeline that contains every animation that you want to play in a specific sequence, but normally the logic sequence is to play them as they appear in the screen (for scroll-based animations and every other scenario to be honest, no much sense in playing an animation for an element that is not in the viewport of course). If you have a ver complex setup with a deep component tree, then it would be a good idea to implement something that tells component deeply nested that the previous component is ready so it's that component's turn to create it's animations. But again, this is a very deep topic that can create a lot of confusion if not handled properly. I remember working on a complex ScrollTrigger based site with Next and a lot of nested components and at the end what I did was to create some prop that was passed from the main component to it's children (where each animation was being created) in order to render everything properly. You could use React context for that as well. Keep in mind that in React the render order starts from the most deeply nested child until it reaches the main component in the tree, but again this is something that you have to look into as you work in your projects and not in every single setup since they will vary depending on the project's requirements.

 

Finally in this case this seems to be happening only on development mode so just reverting the GSAP Match Media instance in the component's cleanup function seems to work better:

useLayoutEffect(() => {
  const mm = gsap.matchMedia();

  mm.add(
    "(min-width: 768px)",
    () => {
      const canvas = canvasRef.current;
      const ctx = canvas.getContext("2d");

      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;

      const frameCount = 148;
      const currentFrame = (index) =>
        `https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${index
          .toString()
          .padStart(4, "0")}.jpg`;

      let images = [],
        hero = {
          frame: 1
        };

      const render = () => {
        // let image = images[hero.frame];
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(images[hero.frame], 0, 0);
        console.log(hero.frame);
        //requestAnimationFrame(render);
      };

      for (let i = 0; i < frameCount; i++) {
        let img = new window.Image();
        img.src = currentFrame(i);
        images.push(img);
      }

      images[0].onload = render();

      gsap
        .timeline({
          onUpdate: render,
          scrollTrigger: {
            trigger: ref.current,
            end: () => "+=150%",
            pin: true,
            scrub: 0.5,
            markers: true
          }
        })
        .to(
          hero,
          {
            frame: frameCount - 1,
            snap: "frame",
            ease: "none",
            duration: 1
          },
          0
        );
    },
    ref
  );
  return () => mm.revert(); // <- Cleanup!
}, []);

Hopefully this clears things up a bit. Let us know if you have more questions.

 

Happy Tweening!

  • Like 1
  • Thanks 1
Link to comment
Share on other sites

One of the issues, I'm still struggling with above is the pinning of elements. I'm using `matchMedia` so believe should have context when using that but still find the markers appear to be added twice.

A hard refresh generally resolves the issue but edits made locally just appear to add a second instance. 

 

I'm working on trying to replicate it in a code sandbox, but at the moment I can't so narrowing down the issue is proving problematic. So getting some extra questions down then tomorrow I'll have another go simplifying it.

 

  • Is it okay to pass in the window width and height as dependencies to the useLayoutEffect? or should I be using a different method for resizing within GSAP?
  • I'm using start, end for the scroll trigger and then placing them offscreen, could this be the cause 
Link to comment
Share on other sites

Okay I think I have an example, 
My goals: have multiple sections that can scrub the animation whilst pinned. 

In the example below, I'm trying to pin the blue section and then animate the content in the pink section on scroll. 

 

When I view initial instance of the 2nd scrollTrigger (in the blue section) it applies the triggers to the intended place, however a hard refresh causes the locations to jump to the preview section.

 

I'm finding I get to a point where I think I've resolved the issue only to hard refresh the page and see it break, what am I doing wrong?


https://dzzwzs-3000.preview.csb.app/

image.png.9fd01e0869fcec929473555c6893467c.png

image.png.918dd0b1f805ed0ec8866052ac638569.png

Sandbox link: https://codesandbox.io/p/sandbox/multiple-scrolltriggers-with-pins-v2-dzzwzs?file=%2Fcomponents%2FAnotherScroll.tsx

  • Like 1
Link to comment
Share on other sites

From what I can tell, removing one of the sections that contain a scroll trigger work, so with that I'm assuming it's related to me having two scrollTrigger instances on the page, potentially the second one not knowing that the first is going to pin the content? and as such places the start (markers) in the original place where the section would start? 

 

I created a couple of other tests which use the same components in different orders and they appear to work correctly. 

https://dzzwzs-3000.preview.csb.app/test1

https://dzzwzs-3000.preview.csb.app/test3

 

 

  • Like 1
Link to comment
Share on other sites

Hey there! Your example is read only so I can't tweak it. I'm also struggling to dig into it. There's also a lot going on, it's pretty hard to follow. I'd advise stripping it back a little.

Some feedback -


Your markers look very chaotic! They're all starting and ending in completely different places. That's a big red flag. If you're going to have components that are interchangeable and pin you need to make sure the triggers don't overlap, or you end up getting logic gaps and everything gets tangly.

 

I always create a minimal demo of the animation I'm working on with vanilla JS and limited styling before trying to implement in a framework, otherwise it's wildly unclear where the issues are, is it a React/render loop thing, is it a styling thing, is it a ScrollTrigger thing? Who knows. Simple is best.

 

Here's a super simplified down example which may help to mess about with -

See the Pen KKeGNYy by GreenSock (@GreenSock) on CodePen



It's very important that you create your scrollTriggers in the order they appear on the page. You can get around this with refreshPriority if you need to...
 

from the docs
 

refreshPriority number - it's VERY unlikely that you'd need to define a refreshPriority as long as you create your ScrollTriggers in the order they'd happen on the page (top-to-bottom or left-to-right)...which we strongly recommend doing. Otherwise, use refreshPriority to influence the order in which ScrollTriggers get refreshed to ensure that the pinning distance gets added to the start/end values of subsequent ScrollTriggers further down the page (that's why order matters). See the sort() method for details. A ScrollTrigger with refreshPriority: 1 will get refreshed earlier than one with refreshPriority: 0 (the default). You're welcome to use negative numbers too, and you can assign the same number to multiple ScrollTriggers.



There's also some other bits like preventOverlaps that may help, but honestly, half the battle is just spacing components nicely and making sure your triggers aren't all overlapping.

fyi I also noticed you have a typo in your prefers reduced motion declaration - good on you for adding that, but just a heads-up that won't work atm.

 

// typo
"(prefers-reduced-motion: no-prefers)"

// good
"(prefers-reduced-motion: no-preference)"

 

  • Like 2
  • Thanks 1
Link to comment
Share on other sites

Thank you Cassie, 

that's really useful information, I made all the markers different just so they wouldn't overlap, I was struggling to see when each one was firing.

 

I'll carry on trying to slim it down, my assumption is that it's react but I'm not entirely certain. 

It's a shame about the CS being read only as well, I was trying to replicate my local setup incase that was the issue, but clearly that's failed. 

 

A part I don't understand is why it works when using the dev task but a refresh breaks. But as you mentioned it could be that the 2nd component is loading first and out of order. The first scroll instance does have lots of images.

Link to comment
Share on other sites

Okay thanks for your help everyone, I think I've got this now. 

The issue I had was that the components do appear to load out of order, when in running in dev mode nextjs hotreloads, by remounting certain components, when that happens the existing ScrollTrigger is already on the page and loaded, however in some cases the extra module is loading first. 

It also explains why a reload would break the layout but working on the element does not display the issue. 
refreshPriority. resolves the issue for, so now I'm currently attempting to refactor to help load.

 

  • Like 1
Link to comment
Share on other sites

Quote

that's really useful information, I made all the markers different just so they wouldn't overlap, I was struggling to see when each one was firing.


Ah yeah, I didn't mean color and size, that's a great way to differentiate, I was just referring to start and end points. There looked to be some overlapping going on.

Glad refreshPriority helped! 

Link to comment
Share on other sites

  • 1 month later...

I wanted to come back and say thank you once again to everyone, you spent your own time to share knowledge. I really appreciate it, it's one of the reasons why when someone comes to me with a design idea my thoughts are often with GSAP as the product is great but the community and support in this forum is top notch.

 

I also wanted to round this off and share some things I learnt along the way, with the hope that they will be useful to someone in the future. 

  • React and Next can be tricky to work with: Read the guides and the forums :)
  • Use useLayoutEffect over useEffect or the custom hook from the advanced React  documentation useIsomorphicLayoutEffect
  • Hot reloading can cause pinning issues if (I assume) the content is dynamically imported
    • Hydration did appear to cause issues if multiple elements are pinned.
    • I thought refreshPriority helped but it turns out it was a bit of a red herring and at times I was able to break the render by resizing the window. 
    • I broke the pinned sections by trying to load components dynamically, using NextJS Dynamic Imports (https://nextjs.org/docs/advanced-features/dynamic-import). Whilst this helped with the page load at times it caused issues with the pinned sections. Changing to imports resolved most of the issues.  
  • Sometimes using a ref is easier:
    Another of my limitations here, and probably a case of me using the wrong thing. I was using typescript and when trying to use gsap.utils.selector(app) I ran into some errors when trying to cast q = gsap.utils.selector(canvasRef) as a canvas element, in the end I reverted to using an extra reference, which appeared to correctly infer the types.
  • Get window.innerHeight and window.outerHeight are different and relying on the innerHeight can cause issues on iOs devices. Especially if you are setting the canvas to those heights.
  • Break the problem down into a smaller chunks
    Too often I get swept up in 'ohh that looks nice' then reviewing the forums and examples to try and adapt existing functionality to fit the desired outcome.  The common advice across the forum is create a minimal demo, so with that in mind I guess that will be my New Years resolution.
    Take an idea and try and split it into something very simple, without hitting the forum first.  

This is the end result, I've cut lots of code out,  it's still not a minimal example but indicates the functionality I'm was trying to achieve.

https://zgbxt9.csb.app/

I'm really hoping that I can play with GSAP again I only tend to do so every 1-2 years and this time I had to try and use additional technologies that I'm learning about as I go. But always enjoy learning about it as I go. 

 

Thank you again and Happy New Year

  • Thanks 2
Link to comment
Share on other sites

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...