Jump to content
GreenSock

Search In
  • More options...
Find results that contain...
Find results in...
trych

How can I prevent my ScrollTrigger scrub animation from jumping in the end?

Go to solution Solved by GreenSock,

Recommended Posts

Hi there,

I am fairly new here, but quite exited about gsap.

 

I am working on a page that uses several scroll trigger to handle some pinned sections. All these scroll triggers handle the same set of circles to animate between different positions. I have set this up in a way now that these circles spread over the width of the page in the end.

 

If I scroll slowly, this works fine. However, if I scroll quickly to the end, they all snap to the 50vw x-Position of the page. I don't know why this happens, especially not why this only happens on quick scrolling and works fine on slow scrolling.

 

I also noticed this stops happening when I change the mid section scroll trigger to not use scrubbing, so although this happens in the last section, the issue also seems to be connected to the ScrollTrigger of the previous section.

How could I fix this, so that it does not jump anymore and spreads the circles across the page reliably with the last ScrollTrigger?

Thanks a lot!
trych

P.S.: I have read the Most Common ScrollTrigger Mistakes article, but none of the measures that are described here seem to help in my scenario.

See the Pen VwpmWew by trych (@trych) on CodePen

  • Like 1
Link to comment
Share on other sites

Hi!

This is a strange one. I assume it's because the tweens in the third timeline are conflicting with the second timeline but I can't quite pin it down, and as you say - it only occurs when you scroll quickly. I tried overwrite true and some other things, but no luck.

It's annoying to suggest a fix without a reason, so apologies. But if I were in your position I would handle the section pinning in one timeline and the dot animation in another - then there aren't conflicts (however they're arising and however strange...)


See the Pen 89e49c2af76bc0dca02749e75ce54dee?editors=0110 by cassie-codes (@cassie-codes) on CodePen

Link to comment
Share on other sites

6 minutes ago, Cassie said:

But if I were in your position I would handle the section pinning in one timeline and the dot animation in another

Thanks for looking into this, @Cassie.


If I separate these timelines, I guess I would need to have one large timeline for the dots. How could I then make sure that the correct parts are synchronized with the pinned sections and still scrubs along? I thought I have to trigger the dot animations via the section scroll triggers to do that. Is there any other way to achieve this?

Thanks!

Edit: Aaaah, only realized now that you posted sample code, sorry! Will look into this and then be back.

  • Like 1
Link to comment
Share on other sites

Ok, I looked into this, however, I guess my question still stands: How do I synchronize these two animations in a way that the "collecting" and "spreading" part happen exactly at the right moment when their corresponding sections are pinned (what I have here is a simplified example, I am afraid the real thing will have more sections).

@Cassie In your code sample for example the final spreading part of the dots starts before the "spreading" section is pinned. I would like to somehow synchronize these two things as best as possible. Is there a way to do this and to still keep the scrubbing? Do I need to work with durations maybe? If so, how do the durations translate to the scrubbing?

Link to comment
Share on other sites

I've simplified the pen slightly and included a wrapper around some sections. This should make it easier to visualise.

In scrolltrigger world when you tie an animation to scroll, time = distance.

See the Pen 4a06dd93c550d70fc4573b84ee1c2782?editors=1010 by cassie-codes (@cassie-codes) on CodePen


The timeline controlling the square is tied to a scrolltrigger that lasts for the time that the sections are in view - the distance of those sections.

so if you have a timeline comprised of four tweens - each one taking a second to complete, each one will play for a quarter of the distance scrolled.

The amount of time a tween takes in relation to the overall timeline will be equal to the amount of distance that tween will play for in relation to the overall distance scrolled.

  • Like 1
Link to comment
Share on other sites

Thanks again @Cassie, I'm studying the example and I think I understand.

Let's say I wanted the animation always to end with each end of the pin (currently they end at the start of the next pin), then I can insert +=1 offsets between the timeline steps, it seems and I will have to add one extra second of nothing to the end of the animation (is there any proper way of doing this, instead of animating the scale from 1 to 1?). According to some quick testing, this seems to work.

Too bad, that this will have to be synced up manually somehow, but I will see if I can get this to work.

If anyone still knows what the original bug might be all about, I would still be interested to know!

  • Like 1
Link to comment
Share on other sites

You can just add an empty tween like this ☺️

 

.to({}, {duration: 2})

 

  • Like 1
Link to comment
Share on other sites

  • Solution
On 5/19/2021 at 8:23 AM, trych said:

If anyone still knows what the original bug might be all about, I would still be interested to know!

It's actually a logic issue in the way you set things up. Let me explain...

 

You've got a scrub: 1 set, meaning the playhead will take a full second to "catch up". Let's imagine your first scrub timeline just animates x from 0 to 100 (to make things simple), and then you've got another tween slightly further down on the page that animates x to 500. If you scroll slowly, everything works - the first goes 0 -> 100, the second goes 100 -> 500. But If you scroll very quickly, let's say that first tween only gets to 10 (due to the scrub "catch up" delay) by the time the second tween gets triggered to render for the first time. Since it's a "to()" tween, it records the starting value as whatever it is NOW (when it renders the first time). So x is 10 in this case (not 100). Our second tween goes 10 -> 500 BUT remember, that first tween is still scrubbing! It's trying to make x go to 100 while at the same time you've got the second tween scrubbing to 500. 

 

The second one "wins" because it renders last, so you won't really notice the first tween competing. However, in your case your second animation was actually a timeline that only had the competing tween in the first half of it (you had an opacity tween at the end). 

 

So let's say the first animation is scrubbing to x: 100 over the course of 1 second, and the second animation is scrubbing to x: 500 for the first 0.5 seconds and to an opacity of 0 for the second 0.5 seconds. If they both begin within 0.1 seconds of each other (fast scroll), can you see how the first scrub to x: 100 would finish AFTER the 2nd scrub to x: 500? That's why your circles ended up in the center (from the first tween).

 

You could add an onEnter on the 2nd ScrollTrigger that basically forces the first animation to progress(1) and calls invalidate() on the 2nd one so that it is forced to record the starting values from the ones that the first animation finishes at. Obviously that means that if you scroll quickly, those wrappers will jump at that point but logically that's necessary. Can you see why? 

 

It might help you visualize it if you put an onUpdate in each of the tweens and do a console.log("update animation {number}") to see the order of things. 

 

Here's a fork that shows the solution I proposed:

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

 

Minor side note: you could simplify this:

// OLD
wrappers.forEach(w => {
  gsap.set(w, {
    x: gsap.utils.random(circleRadius, (ww - circleRadius)),
    y: gsap.utils.random(circleRadius, (wh - circleRadius))
  })
});

// NEW (use function-based values)
gsap.set(wrappers, {
  x: gsap.utils.random(circleRadius, (ww - circleRadius), true),
  y: gsap.utils.random(circleRadius, (wh - circleRadius), true)
});

One other option (which would require more extensive edits to your layout most likely) is to change scrub: 1 to scrub: true and apply a smooth scroll to the entire page to get the smoothing effect. See the ScrollTrigger.scrollerProxy() page for various options, including a native ScrollTrigger one. 

 

Does that clear things up? 

  • Like 2
Link to comment
Share on other sites

@GreenSock Wow, thank you so much for the taking the time to walk me through this, this is very appreciated. And very well explained. Although this seems like a somewhat tricky logic issue, I was now able to understand it.
 

3 hours ago, GreenSock said:

One other option (which would require more extensive edits to your layout most likely) is to change scrub: 1 to scrub: true and apply a smooth scroll to the entire page to get the smoothing effect. See the ScrollTrigger.scrollerProxy() page for various options, including a native ScrollTrigger one. 


Wow, I hadn't even seen the method subpages of the ScrollTrigger plugin docs. 😳
This is actually a very valuable option for me, as I do have "choppiness" issues when scrolling and using a regular mouse (as opposed to my trackpad), so I will definitively look into this, thank you. In your experience, are these scrolljacking libraries reliable enough to handle all scrolling scenarios? Or do they tend to break on different devices due to their added complexity? While I would love the smoother scrolling, I cannot really risk for the site to not properly work on many devices.

Link to comment
Share on other sites

This is a great explanation, thank you Jack!

  • Like 1
Link to comment
Share on other sites

9 hours ago, trych said:

In your experience, are these scrolljacking libraries reliable enough to handle all scrolling scenarios? Or do they tend to break on different devices due to their added complexity? While I would love the smoother scrolling, I cannot really risk for the site to not properly work on many devices.

This is part of why I specifically designed ScrollTrigger NOT to do scroll-jacking. Seemed risky. I wanted it to integrate seamlessly with native scrolling for all devices. 

 

I cannot speak to smooth-scrolling libraries because it's not in my wheelhouse. They may have worked around all the issues on various devices, I just don't know. All I can say is that I built the native ScrollTrigger helper function one to only have one layer of abstraction and tap into the native scrolling as much as possible to deliver its functionality but I don't know with 100% certainty that there aren't edge cases where it wouldn't perform the way you'd expect. Let me know if you run into any issues, of course. But it's relatively concise and you can see exactly what's going on in that function, so hopefully it's straightforward and not prone to issues on various devices. 

 

9 hours ago, trych said:

Wow, thank you so much for the taking the time to walk me through this, this is very appreciated. And very well explained. Although this seems like a somewhat tricky logic issue, I was now able to understand it.

🙌

Link to comment
Share on other sites

I'm afraid, I'll have to ask again about this issue.
Now that I am setting up the actual, much more complex circle animation, I am still trying to figure out the best way to sync up the pinned sections with the circle animations.

@Cassie's method of setting them up independently gives a lot of flexibility, but is really tricky to set up once not every tween is conveniently 1 second long: I have to carefully match everything up, insert empty tweens for pauses and even with easier setups there happened lots of mistakes. And once there is a change, I will have to make changes to a few places in my code to have everything in sync again.

The method shown by @GreenSock links the circle animations directly to the pinned sections. This would work, but I have two problems with this:

  1. I cannot create and maintain my complex circle animation in a single timeline but have to split it up over many Scroll Trigger animations.
  2. (more importantly) The animation part is always just happening as long as the pinning is active, then it stops animating until it hits the next pinned section. However – contrary to what I said above –, in some places I need to keep animating the circles even between the pinned sections (meaning in the time between the end of the previous pin and the start of the next pin). This seems not easily possible (I might be wrong though?).

To overcome the first issue, I thought I could use a scrolltrigger animation with a pinned section to "drive" a circle animation timeline. So I had something like this:

gsap.to(circleAnimationTl, {
  scrollTrigger: {
	trigger: mySection,
	scrub: true,
	pin: true,
	start: "top top",
	end: "+=100%"
  },
  progress: circleAnimationTl.labels["circlesCollected"] / circleAnimationTl.duration()
});

While this generally allows me to drive the animation from label to label and would be preferrable, because I can maintain my circle animation timeline as a single, connected timeline, it still suffers from the issue that the animation stops between the pinnings.

So I guess, my question comes mostly down to this: Is there are way to have the pinned animation run until the very end, even after the pin is released and until it reaches the next section? Or can I fake this somehow? Or does someone have a different idea for an easier approach to this whole thing?

Thank you!

Link to comment
Share on other sites

I guess I don't really understand what you're after here - it some ways it sounds like you want the animations directly tied to the scroll position (scrub), yet you don't inbetween sections(?) And you want to build everything in one timeline but then sometimes you're going to play that timeline independently of the scroll...but then if the user scrolls how would you expect it to somehow come back into conformity with what the scroll position dictates? It sorta sounds like you may have some logically impossible behavior in mind, but I might just be misunderstanding. 

 

I think it'd really help if you created the absolute minimal demo that shows what you're trying to do so we can try to get our heads around it. 

 

2 hours ago, trych said:

Is there are way to have the pinned animation run until the very end, even after the pin is released and until it reaches the next section?

It's either directly linked to the scroll position (scrub) -OR- it's not in which case you can use toggleActions. 

 

I mean honestly you can probably do almost anything that's not logically impossible :)

 

Sometimes I see people get a very particular path in mind ("if a user scrolls, then stops, everything should keep animating...but it should also be directly linked to the scrollbar position when they scroll again," etc.) and they don't think about the implications of that or why it would be logically impossible. For example, if you link things to the scrollbar position (scrub) and the user stops at progress: 0.9 but you make that keep going to 1, what happens when the user scrolls backwards slightly? Should it jump? Should it be out of alignment with the scroll position? If you go that route, you can create scenarios where it's impossible for the user to ever get back to the beginning of the animation. 

Link to comment
Share on other sites

54 minutes ago, GreenSock said:

I guess I don't really understand what you're after here - it some ways it sounds like you want the animations directly tied to the scroll position (scrub), yet you don't inbetween sections(?) And you want to build everything in one timeline but then sometimes you're going to play that timeline independently of the scroll...

Yes, you seem to have misunderstood me. At no point do I want to play the timeline independently of the scroll. :)

I am basically after the exact same thing that is shown in @Cassie's first CodePen Sketch above (the one that is based on my initial Codepen). So I also know that the animation itself is not logically impossible, as that Codepen demonstrates the exact animation I am after.

The difference to the Codepen sketch you posted is that the circle animations play up to the point where the next pin starts (and don't end with the end of a pin, then pause and continue at the beginning of the next pin).

Cassie achieves that behavior by matching the number of sections with the number of Tweens in the timeline. However, when I have now more tweens in my timeline or tweens that are longer than others, I have to manually correct for this (I would basically have to make the scrolltrigger containers larger to match the longer tweens of the circle), if I still want to sections to exactly be in sync.

That turned out to be complicated and I would like to avoid that. Hence my idea to drive circle animation from label to label via the scrolltriggers as my code snippet from the previous post shows. However, the scrolltriggers always stop animating the moment they stop the pinning. Then there is a pause until it scrolls down to the start of the next pinned section and then it keeps animating. So there will be a pause in my circle animation between the end of a pinning and the beginning of the next one.

I don't want that pause, but basically want to animate the "collecting" or "spreading" or whatever to the point where the next pinning starts (as in Cassie's sketch). However, I don't know how to do that or if there is even a way.

Link to comment
Share on other sites

Ok, this took me forever (because I am both new to Scrolltrigger and Codepen ;) ), but I constructed a minimal demo of what I need. Here we go:

See the Pen abJWQby by trych (@trych) on CodePen


And then here is a demo of the problem that happens when I now want to modify the animation (add some more tweens for example, it gets all out of sync:

See the Pen oNZWJLK by trych (@trych) on CodePen

And I wonder, if this issue could be solved with labels somehow, that the section animate from label to label, but with pinning still happening and under the condition that the animation also keeps animating between the pinnings, as shown in these two sketches.

@GreenSock, I hope my issue becomes more clear with the examples?

Link to comment
Share on other sites

I think the main issue here is expecting animation timelines not to be fiddly and manual. 😅

Getting things to look *just right* involves a lot of manual faffing about most of the time. There is literally no solution that will guarantee you don't have to faff about some time in the future if you change something.

Animation is a faff. A fun one, but a faff nonetheless. The best thing about GSAP is it's easy to do the faffing. You can switch things up, slow things down, change orders of tweens, change start points, adjust trigger containers, use relative or absolute values for timings and positionings. etc etc.

Here's another solution, we're back at the previous idea of multiple timelines (which is ok now as you're using scrub:true instead of scrub:1, so you don't have the overwriting happening) It's all about working out what's most important for your particular problem and prioritising those things in your approach.

See the Pen oNZWJLK?editors=0010 by trych (@trych) on CodePen

Link to comment
Share on other sites

37 minutes ago, Cassie said:

I think the main issue here is expecting animation timelines not to be fiddly and manual. 😅

Getting things to look *just right* involves a lot of manual faffing about most of the time. There is literally *no* solution that will guarantee you don't have to faff about some time in the future if you change something.

Fair enough. :) I was certainly expecting some amount of fiddling and manual work, I was just hoping, I could find at least a way to sync up certain points of these timelines that run in parallel.


I believe you did post my Codepen sketch by accident. Could you edit the link, so I could see the solution you came up with? Thanks!

Link to comment
Share on other sites

There's also this thread on animating between labels if you're interested - but I would probably stick with the above solution!
 


TLDR
 

let master = gsap.timeline({paused: true});
master.to(...); // do whatever
master.addLabel("label1", 2).addLabel("label2", 5);

let labelTween = master.tweenFromTo("label1", "label2");
ScrollTrigger.create({
  animation: labelTween,
  ...
});

 

Link to comment
Share on other sites

2 hours ago, Cassie said:

Oh I did! Sorry - here you go. It's the same as your first solution because you've changed the scrub value so the overlap isn't an issue now.

Thanks, this one looks interesting. The end: "bottom+=100% top" part specifically solves the issue of the breaks. I still would prefer to have the circle animations all in one timeline, but from all the options I had so far, this one seems the simplest. Will give this a try. Thanks again!

  • Like 1
Link to comment
Share on other sites

Pleasure, Glad we could help.

Link to comment
Share on other sites

Okay, so I couldn't help myself - I had to see if it was feasible to craft a helper function that'd do all the hard math and dynamically figure out how long to pin each section based on where you put labels in your timeline like "pin1", "pin2", "pin3", etc. 

 

Here's what I came up with:

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

 

The helper function:

// helper function
// parameters: timeline, elements to pin (can be an Array of elements or selector text), minimum pin distance (defaults to 300px)
function setupTimelinePins(tl, sections, minPinLength) {
	sections = gsap.utils.toArray(sections);
	minPinLength = minPinLength || 300; // default to 300px
	let distances, ratios, multiplier, triggers,
		st = tl.scrollTrigger,
		labels = tl.labels,
		totalDuration = tl.totalDuration(),
		findNextPin = i => {
			let num = i,
				pin;
			while (!pin && i < sections.length && pin !== 0) {
				pin = labels["pin" + (num+1)];
				!pin && pin !== 0 && sections.splice(i, 1); // didn't find, so delete from sections.
				num++;
			}
			return pin || pin === 0 ? num : null;
		},
		durations = [],
		calculate = () => { // wrap this logic in a function so we can re-calculate on resize (responsive)
			triggers && triggers.forEach(t => t.revert());
			st.refresh(); // calculate the start/end values
			triggers && triggers.forEach(t => t.revert());
			let totalDistance = 0,
				firstTop = sections[0].offsetTop,
				firstST = triggers ? triggers[0] : ScrollTrigger.create({
					trigger: sections[0],
					start: "top top",
					end: "+=100%"
				});
			if (triggers) {
				triggers.forEach(t => t.revert()); // only necessary for 3.6.1 and earlier
				firstST.refresh();
				triggers.forEach(t => t.revert()); // firstST.refresh() can trigger pinning/padding/layout changes
			}
			distances = sections.map((section, i) => { // scroll distance between each section's start
				let next = sections[i + 1],
					distance = next ? next.offsetTop - section.offsetTop : st.end - (firstST.start + (section.offsetTop - firstTop));
				totalDistance += distance;
				return distance + minPinLength; // factor in the minimum pin length
			});
			totalDistance += sections.length * minPinLength;
			ratios = sections.map((section, i) => ((durations[i] / totalDuration) / (distances[i] / totalDistance)) || 0);
			multiplier = 1 / Math.min(...ratios);
		},
		l = sections.length,
		cur = findNextPin(0),
		i, next;
	for (i = 1; i <= l; i++) { // calculate the time between pin labels in the timeline. If a pin is missing, remove it from the sections Array (skip)
		next = findNextPin(i);
		durations.push((next ? labels["pin" + next] : totalDuration) - labels["pin" + cur]);
		cur = next;
	}
	durations.forEach((n, i) => n || (durations.splice(i, 1) && sections.splice(i, 1))); // don't allow zero durations or pinning will be infinite.
	calculate();
	triggers = sections.map((section, i) => {
		return ScrollTrigger.create({
			trigger: section,
			pin: true,
			start: "top top",
			refreshPriority: 100 - i, // to ensure these resize before the parent refreshes, thus it includes the pinning adjustments
			end: () => "+=" + (distances[i] * (ratios[i] * multiplier - 1) + minPinLength) // use function-based to make it responsive
		});
	});
	ScrollTrigger.addEventListener("refreshInit", calculate); // to make responsive
	ScrollTrigger.refresh();
}

Usage is pretty simple - you pass in your timeline, the elements to pin (as selector text or an Array of elements) and a minimum amount to pin (it defaults to 300px as a minimum):

setupTimelinePins(s3, ".pinnedsection", 300);

It dynamically searches your timeline for labels like "pin1", "pin2", etc. and then measures the time between each one and allocates pinning accordingly so that if you've got a lot more time between "pin1" and "pin2", that element will stay pinned longer. 

 

Maybe it won't help you, but it was an interesting thought experiment for me. 

 

This way, you can just build your animation, drop in your labels, and BOOM, it handles the rest. I even made it responsive (although you'd have to make sure your animations are responsive as well). 

 

@trych, is that helpful at all? 

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

JACK.

This is amazing. 😂 Best forums of all time.

  • Thanks 1
Link to comment
Share on other sites

@GreenSock 😳

Wow, thanks so much! This seems exactly like what I am looking for.
My project's timeline was yesterday unfortunately and after trying around a lot I ended up using @Cassie's first method. However, when they require some more changes, I might use your helper function (as my project has the circle animation set up as a single timeline now anyways) and even if not, I'm sure this can come in handy for me and others in the future.

Only one question though: What does the last parameter do? If I set it to 300px, then how does it act in my setup?

Again, thanks so much! This forum truly is amazing.

Link to comment
Share on other sites

8 minutes ago, trych said:

Only one question though: What does the last parameter do? If I set it to 300px, then how does it act in my setup?

The last parameter is the minimum pinning duration. It uses that as a baseline to scale all the other (longer) pinning distances. So in the demo, there are only 2 elements getting pinned; one maps to a duration on the timeline of 1 second, and the other maps to a duration of 3 seconds. It figures out the shortest one (1-second in this case) and ensures that element gets pinned for exactly 300px and then calculates the other one accordingly (longer, of course). 

 

If you changed the 300 to be 1000, for example, everything gets scaled up accordingly in terms of how long things get pinned; it'll take more scrolling to get through. 

 

Does that clear things up? 

 

Sorry this helper function didn't come before your deadline, but hopefully it'll help someone in the future. At the very least, it was a fun challenge for me that took me quite a few hours :)

  • Like 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.
×