Jump to content
Search Community

How to manage multiple ScrollTrigger-based timelines in React parent/child components

alvinteh test
Moderator Tag

Go to solution Solved by alvinteh,

Recommended Posts

I have a React project with multiple <Page> components (roughly equivalent to a webpage), that each have one or more <Scene> components (take them as sections within a webpage). These <Scene> components have independent ScrollTrigger-based timelines and animations that work well.

 

I would also like to add ScrollTrigger-based parallax animations to the <Page>components. In the CodePen, this is illustrated on the <Scene>level by the top -= 100vh animation applied to the <SubContent> elements. (i.e. I would like the same animation applied to <SceneB>, as depicted in the commented-out code). What is the best way for me to achieve this?

My understanding is that nesting ScrollTriggers is generally a no-no due to conflicting logic between the parent/child timelines. However, I would also like to keep <Scene>-specific timeline information (particularly the duration) within <Scene>s (e.g.  <SceneA>should not know about <SceneB>'s timelines). While I'm happy to do some refactoring, I would like to avoid having to make complicated changes to individual timeline.to/from calls where possible (there are already dozens of animations). Combining <Scene>s is also not an option as many of them are large (500+ LOC).

 

Currently, my guess is that I'll need to merge all of the timelines in each <Scene>into one timeline on the <Page>level. However, if I do so, how do I synchronize the start of each <Scene>'s animations to the original trigger and start? In addition, how would I pass the end in each <Scene>to the <Page>-level timeline?

 

Or is there another better way to do this?

See the Pen gOErYoj?editors=0010 by alvinteh (@alvinteh) on CodePen

Link to comment
Share on other sites

I have made a quick attempt at my guessed solution and there are other issues, namely how do I pass the timeline to the <Scene> components, and how to time tweens between <Scene>s).

 

CodePen here: 

See the Pen wvOGByr?editors=0010 by alvinteh (@alvinteh) on CodePen



(apologies, I haven't been able to figure out how to add a CodePen in a reply rather than a new post).
EDIT: scrapped apologies as I see the CodePen link results in an automatic embed.

Link to comment
Share on other sites

I'm not sure I understand your goal correctly, but doesn't your approach have fundamental logic flaws/challenges?

 

You are setting up a ScrollTriggered animation in a child component which is completely dependent on scroll, but you also want the parent to have a ScrollTriggered animation that can only happen AFTER the child's is done? What if the scrolling positions conflict? For example, child scrubs from scroll position of 100-400, and the parent's scrubs from a scroll position of 200-500 and then you scroll to 300? Technically the child's animation would NOT be completed, so you don't want your parent's to start...but the scroll positions of the parent's would already be partially done. It's logically impossible to accommodate something like that. The scrub position can't be in two places at once. And if even if you wired up logic to force the parent's animation not to start until the other one finishes, it could suddenly JUMP to the position it's supposed to be in according to its start/end values. 

 

So if I'm understanding you correctly, you've got two challenges: 

  1. Logical impossibilities 
  2. React-specific engineering issues. 

We're not React experts here - we really try to keep these forums focused on GSAP-specific questions (see the forum guidelines). 

 

Here's a fork that shows how you can use the useGSAP() hook as a drop-in replacement for useLayoutEffect()/useEffect(). Your previous demo wasn't doing proper cleanup and the animations weren't set up to be responsive. 

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

 

I hope that gives you a little nudge in the right direction. 

  • Like 1
Link to comment
Share on other sites

I do not intend for there to be conflicts between the parent and child ScrollTriggers.

 

I've created another fork of the original CodePen, but with all of the <Scene>s within the <Page>.  The animation in this fork is the intended effect and works correctly.

 

See the Pen poYygba?editors=0010 by alvinteh (@alvinteh) on CodePen

 

I guess another way of phrasing my question is: given the above, how should I go about splitting the <Scene>s into individual components while still maintaining a single ScrollTrigger-based timeline?

 

PS: Thanks for helping with the useGSAP() hook! I've been using that in my actual code but for some reason, CodePen refused to import it when I was making this post, hence the use of useLayoutEffect().

 

 

Link to comment
Share on other sites

That sounds like a React plumbing question. We really try to keep these forums focused on GSAP-specific questions (we're not React experts), but I'd recommend reading this: 

https://gsap.com/resources/react-advanced

 

Here's one approach: 

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

 

If it's a GSAP-specific question, I'd suggest removing React from the equation and just use a vanilla JS CodePen to illustrate the issue/question and we'd be happy to take a look at that. 

 

I hope that helps!

Link to comment
Share on other sites

Thanks for the updated CodePen, it is very helpful! I'm trying it on my end with some adjustments to accommodate multiple animations per <Scene>, and had a few quick GSAP questions.

Namely, I noticed in the provided CodePen and article that gsap.to/from/...() is used instead of tl.to/from/...(). (For brevity's sake, I'll just mention the to() methods).

 

1. What would be the equivalent way of supplying the position parameter (in tl.to()) with gsap.to()? I see the tl.to() is a convenience method for tl.add(gsap.to(...)) but don't see any equivalent  of the position parameter in the gsap.to() documentation.

 

2. I see tl.add() supports different types of child parameters. Just for my understanding, what is the difference between calling tl.add(anotherTimeline) versus tl.add(gsap.to()) multiple times (one for each tween)?

 

For #2, it would also be great to understand the implications, if any, of calling tl.add(anotherTimeline) if the parent timeline has a ScrollTrigger (but not the child timeline, that is only a gsap.timeline({})). Context: I'm trying to understand if it's better/easier for child components to pass a timeline (as opposed to individual tweens) to the parent timeline, particularly if #1 is difficult to achieve.

Link to comment
Share on other sites

9 hours ago, alvinteh said:

1. What would be the equivalent way of supplying the position parameter (in tl.to()) with gsap.to()? I see the tl.to() is a convenience method for tl.add(gsap.to(...)) but don't see any equivalent  of the position parameter in the gsap.to() documentation.

I think you might be misunderstanding some fundamental concepts. It might be good to read through this page: https://gsap.com/docs/v3/GSAP/

 

Setting a position parameter on a single tween doesn't really make sense - a position parameter is for defining where exactly to place an animation into a timeline. If it's just a loose/independent tween, that doesn't apply. Of course you can set a delay if you'd like. 

 

9 hours ago, alvinteh said:

2. I see tl.add() supports different types of child parameters. Just for my understanding, what is the difference between calling tl.add(anotherTimeline) versus tl.add(gsap.to()) multiple times (one for each tween)?

Functionally, there's no difference. Either way, you're just inserting an animation (an animation can be a tween or a timeline). A timeline is simply a wrapper. You can nest timelines within timelines if you'd like. Just DO NOT nest animations that have ScrollTriggers because it's logically impossible to have an animation's playhead controlled BOTH by its parent timeline AND the scroll position. Those could be going in completely different directions. So a ScrollTrigger should only be applied to the top-most level (not nested). 

 

It's a super robust system when you understand the basic mechanics of tweens/timelines/ScrollTriggers. Tweens do all the animation work. Timelines are just wrappers that make it easy to set up the timing of any child animations (sequences), giving you the ability to control entire sequences as a whole. The playheads of all the children are controlled by the parent timeline. 

 

I hope that clarifies things. 

  • Like 1
Link to comment
Share on other sites

  • Solution

Thanks a lot for the help @GreenSock! As a quick update, I had to refactor a few things (primarily CSS-related) but I have managed to get things working. I don't have a working CodePen at the moment, but in a nutshell, my approach was  to:

1. Retain timelines in the <Scene> components; however, I removed ScrollTriggers from them.

2. Adapted the callback section in GSAP Advanced React guide; I used both React context and callbacks for <Scene>s to  pass their timelines and "container div" refs to the <Page> components (in a useGSAP call). I had to use context as the scenes were being passed via the children prop. i.e. my code was like:

const PageContext = createContext<PageContext>({
	registerScene: () => {},
});
const SceneA = ({ sceneIndex }) => {
	const { registerScene } = useContext(PageContext);

	useGSAP(() => {
		const timeline = gsap.timeline({});
	
		timeline.to(...);
  	
    	registerScene(sceneIndex, sceneRef, timeline);
	}, []);
};

 

3.  In the <Page> component (<PageBase> in the excerpt below), I have a useGSAP call that creates a master timeline with a ScrollTrigger, and stitches together the timelines from each Scene. It also adds animations to transit between each scene.

const ParallaxPageWrapper = styled.div`
	position: relative;
	height: 100vh;
	overflow: hidden;
`;

const PageBase = ({ children }) => {
	const pageRef = useRef();
	const [sceneRefs, setSceneRefs] = useState([]);
  	const [sceneTimelines, setSceneTimelines] = useState([]);
  
	gsap.registerPlugin(ScrollTrigger);

	useGSAP(() => {
    	const sceneCount: number = Children.count(children);

    	if (sceneRefs.length === 0 || sceneRefs.length !== sceneCount || sceneTimelines.length !== sceneCount) {
      		return;
    	}
      
    	const { totalDuration, totalChildren } = sceneTimelines.reduce((prev, sceneTimeline) => {
      		return {
        		totalDuration: prev.totalDuration + sceneTimeline.totalDuration(),
        		totalChildren: prev.totalChildren + sceneTimeline.getChildren().length,
      		};
    	}, { totalDuration: 0, totalChildren: 0 });

    	if (totalChildren === 0) {
      		return;
    	}
    	
      	const timeline = gsap.timeline({
      		scrollTrigger: {
        		trigger: pageRef.current,
        		pin: true,
        		scrub: true,
        		start: 'top top',
        		end: `+=${(totalDuration + sceneCount - 1) * parallaxUnit}`,
      		}
    	});
      
      	for (let i = 0; i < sceneTimelines.length; i++) {
      		const nextSceneRef = sceneRefs[i + 1];
      		const sceneTimeline = sceneTimelines[i];

      		timeline.add(sceneTimeline);

      		if (i < sceneTimelines.length - 1) {
        		timeline.add(gsap.to(nextSceneRef.current, {
          			transform: 'translate3d(0, -100vh, 0)',
          			duration: animationDurations.XSLOW,
        		}));
      		}
        }
    }, [children, sceneRefs, sceneTimelines);
        
	const registerScene = useCallback((index, ref, timeline) => {
		setSceneRefs((prevSceneRefs) => {
			const newSceneRefs = prevSceneRefs.slice();
			newSceneRefs[index] = ref;
			return newSceneRefs;
		});

		setSceneTimelines((prevSceneTimelines) => {
			const newSceneTimelines = prevSceneTimelines.slice();
			newSceneTimelines[index] = timeline;
			return newSceneTimelines;
		});
	}, []);
  
	return (
		<div>
			<PageContext.Provider value={{ registerScene }}>
				<ParallaxPageWrapper ref={pageRef}>
					{children}
				</ParallaxPageWrapper>
			</PageContext.Provider>
		</div>
	);
};

4. From the above, I can now create <Page>s like so:

const TestPage = () => {
	<PageBase>
		<SceneA sceneIndex={0} />
		<SceneB sceneIndex={1} />
	</PageBase>
 );

(Note: I've simplified the code from my original TypeScript code, but the main logic should be there).

 

Some other remarks:

  • I would love to do without sceneIndex but it's the easiest way to ensure scenes don't override each other in <PageBase>.
  • The <div> surrounding <PageContext.Provider> is necessary to avoid React errors. See comment in other GSAP community thread. The useLayoutEffect()/useGSAP() comment later on did not resolve the issue.

 

Hopefully the above helps others trying to achieve similar results.

Link to comment
Share on other sites

1 hour ago, alvinteh said:
  • The <div> surrounding <PageContext.Provider> is necessary to avoid React errors. See comment in other GSAP community thread. The useLayoutEffect()/useGSAP() comment later on did not resolve the issue.

I'm a little fuzzy on exactly what you're talking about here, but I wonder if it just has to do with the fact that whenever you pin an element, ScrollTrigger must fabricate a NEW <div> as a wrapper around that original element so that it can act as a placeholder when the "real" element gets set to position: fixed and is removed from the document flow. So if that pinned element is technically the root element of your component, maybe React is complaining because there was a new element injected there. That's easy to remedy by creating your OWN wrapper <div> that you then give ScrollTrigger as the pinSpacer so that ScrollTrigger doesn't need to do any creation of a new <div> and injecting it into the DOM: 

{
  trigger: ".pinned-element",
  pinSpacer: ".pin-spacer", // <-- your wrapper around .pinned-element
  ...
}

Thanks for sharing your solution(s). 👍

  • Like 1
Link to comment
Share on other sites

Posted (edited)

Yes, you're spot on with regards to the new wrapper element that ScrollTrigger creates around the pinned element causing the issue as it's above/outside the DOM nodes React expects to see in the component.

 

When creating my own wrapper <div> (but without specifying the pinSpacer option),  the ScrollTrigger-generated wrapper still falls within my <div>, which also solves the problem. Specifying the pinSpacer option helps remove one extraneous div though!

EDIT: I think the documentation for pinSpacer option should mention it supports String | Element given it also supports CSS selector text (similar to the trigger option)?

Edited by alvinteh
Added note on Documentation
Link to comment
Share on other sites

8 hours ago, alvinteh said:

EDIT: I think the documentation for pinSpacer option should mention it supports String | Element given it also supports CSS selector text (similar to the trigger option)?

Technically that's true and we'll work on updating that but honestly I don't think it's very practical because in a React context like this, you'd never want to use selector text. It's the root element, so you can't even target that using the scope of a useGSAP() or gsap.context(). So using selector text is largely useless in this kind of scenario. See what I mean? My sample code should have looked more like: 
 

{
  trigger: ".pinned-element",
  pinSpacer: pinSpacerRef.current, // <-- your wrapper around .pinned-element
  ...
}

 

Link to comment
Share on other sites

5 hours ago, GreenSock said:

Technically that's true and we'll work on updating that but honestly I don't think it's very practical because in a React context like this, you'd never want to use selector text. It's the root element, so you can't even target that using the scope of a useGSAP() or gsap.context(). So using selector text is largely useless in this kind of scenario. See what I mean? My sample code should have looked more like: 

 

I agree that refs should be used in favor of selector text in the context of React apps; that's what I actually did after I saw your previous post + the docs! 😊

 

Thanks once again GreenSock!

  • Like 1
Link to comment
Share on other sites

Agreed, but just to be clear - it's totally fine to use selector text when you define a scope. That prevents the selectors from leaking outside the component instance (at least in the vast majority of cases). But that can't work with the root element itself which is what you were dealing with here. Scope means "any descendants of this element" which of course would exclude the element itself 🙂 I just didn't want you to be under the impression that it's always a bad idea to use selector text in React. 

  • Thanks 1
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...