Jump to content
Search Community

ScrollTrigger + ThreeJS - resetting scrub animations after leave

michelleenos

Recommended Posts

Posted

I've made a demo of this issue on codesandbox: https://codesandbox.io/p/sandbox/3qk4yj 

(I know y'all seem to prefer CodePen but I felt it was easier to see the issue with an actual 3d model as an example, which I couldn't load on codepen. I hope this is okay! If it helps I can remake the demo on codepen just using a box instead)

Edit: adding codepen demo!  

 

This is from a project where I am animating a ThreeJS model to different positions as you scroll down the page. I have it set up so that the model is wrapped in a THREE.Group, and most of the animations are just moving that group around using onEnterand onEnterBack callbacks. But in a few sections, there is a scrubbed timeline that moves the model (not the group) to make a zooming-in effect. After leaving those sections where the model zooms in, the model's props need to reset back to their defaults so it's not zoomed-in anymore. Right now I'm using an onLeave callback on the zoom section's wrapper to do that (in the demo this is on the .section-2 trigger).

 

This all works if you just scroll down the page normally, but if you scroll to the end and then resize the window, or if you refresh the page with the scroll position at the end, the "zoom" timeline skips ahead to the end and the model gets zoomed in, even though by that point on the page I need the model to be in its default position. I'm guessing this is just ScrollTrigger's default refresh() behavior, which makes sense in most cases but not in mine. Is there a way to avoid this or make sure that onLeave callback resetting the object gets honored on refresh if we are scrolled past that section? 

 

The main important bits in the demo are in the index.mjs file, in particular the scrolltriggers & timeline on .section-2 and .section-2-end. In section 3, the group is set to hidden because at some points the model will just not be on the page at all, but if it helps to visualize it you can also comment out the code in the section-3 trigger that hides it. 

 

This has been driving me nuts and I'd appreciate any assistance you can give me! 

See the Pen EayVNPO?editors=0010 by crankysparrow (@crankysparrow) on CodePen.

Posted

I have a feeling your issues stem from your ScrollTrigger animations not knowing of each other. Eg there are all separate ScrollTrigger that control separate animation which in turn all control the same element. 

 

What a solution could be is to crate one timeline and add all your tweens to that one timeline, then in your ScrollTriggers play sections of that timeline, this way it can never play a wrong animation, because they are all baked in to the timeline. I think .tweenTo() https://gsap.com/docs/v3/GSAP/Timeline/tweenTo()/ could be a nice feature for this. 

 

I have to say, no 'normal' user is ever scrolling halfway through a page and then resizing a browser, that is only something we as developers do (or clients if they are particular picky). 

 

Codepen is great because (usually) there are no weird permission issues, which is now the case in your codesandbox, I can't edit any of your file! I would figure you can easily load an external file on Codepen, just host your 3D model on yourdomain.com/mymodel.glb and load in in your code, but a box also works. 

 

Hope it helps and happy tweening! 

Posted

@mvaneijgen thank you for the input! Here is a codepen with the same demo: 

See the Pen EayVNPO?editors=0010 by crankysparrow (@crankysparrow) on CodePen.

 

 

Creating one main timeline is an interesting idea, however... in my actual project, in each section the object actually moves to match up with a specific dom element. So there is another bunch of logic to figure out that dom element's position & size and update the threejs object to match it, then that element also gets tracked on scroll/resize so the three object can stay in sync. I'm not sure how I'd integrate that into one main timeline, since the animations moving the element between each dom item can get created & re-created several times as you scroll through. 

 

ALTHOUGH - all of that syncing of the object with dom elements only affects the THREE.Group wrapper, it doesn't actually effect the object itself. So perhaps I could use a global timeline as you suggest for just the animations on the actual object? Hmm... another question I have is, if all the tweens for that object were collected in one timeline, would I be able to have some of them be scrubbed and some not? Maybe the scrubbed bits would just have to hook into an "onUpdate" to a scrolltrigger somewhere? 

 

It's a good point that users aren't likely to scroll partway and then resize, but especially since it happens on literally *any* resize it just makes the experience feel so fragile and easily breakable.  The actual model we're using is of a fruit fly, so when it gets randomly enlarged and flipped upside down it's a little unsetting...LOL.

Posted

Hi

On 12/31/2025 at 5:04 PM, michelleenos said:

Creating one main timeline is an interesting idea, however... in my actual project, in each section the object actually moves to match up with a specific dom element. So there is another bunch of logic to figure out that dom element's position & size and update the threejs object to match it, then that element also gets tracked on scroll/resize so the three object can stay in sync. I'm not sure how I'd integrate that into one main timeline, since the animations moving the element between each dom item can get created & re-created several times as you scroll through. 

I'm not 100% sure I follow what you're trying to achieve specifically here, you mention matching the size and positions of different elements, but for the DOM element containing the THREEJS canvas. Perhaps this demo that uses the Flip Plugin could help:

https://demos.gsap.com/demo/threejs-scroll-waypoints/

 

On 12/31/2025 at 5:04 PM, michelleenos said:

f all the tweens for that object were collected in one timeline, would I be able to have some of them be scrubbed and some not? Maybe the scrubbed bits would just have to hook into an "onUpdate" to a scrolltrigger somewhere? 

Indeed, you can't have some scrubbed tweens and some not scrubbed in a single Timeline, you would need some custom logic for that. Since each animation is being triggered in different sections, perhaps the best approach is the one you already have in place, that is trigger different animations in different ways (some scrubbed and some not) on each section.

 

Finally I did some fiddling with your demo and found out that commenting out this has the same effect the resize event has after passing the last trigger:

// section 2 - point downwards
new ScrollTrigger({
  trigger: ".section-2",
  markers: {
    indent: 100,
    startColor: "#f0f",
    endColor: "#f0f",
    fontSize: "12px",
  },
  id: 2,
  start: "top 50%",
  end: "bottom top",
  onEnter: () => toState(1),
  onEnterBack: () => toState(1),
  // Comment this out only for test purposes
  // LEAVE THE CALLBACK IN PRODUCTION
  //onLeave: () => resetObjectZoom(),
});

So clearly when resizing something is not really working as expected, so the solution is to add the same method to the onRefresh callback of the next ScrollTrigger instance:

// section 4 - look to right
new ScrollTrigger({
  trigger: ".section-4",
  markers: {
    startColor: "#aaf",
    endColor: "#aaf",
    fontSize: "12px",
    indent: 450,
  },
  id: 4,
  start: "top 50%",
  end: "bottom 50%",
  onRefresh: resetObjectZoom,
  onEnter: () => toState(2),
});

That seems to make it work, at least in the codepen demo.

 

Hopefully this helps

Happy Tweening!

michelleenos
Posted
4 hours ago, Rodrigo said:

I'm not 100% sure I follow what you're trying to achieve specifically here, you mention matching the size and positions of different elements, but for the DOM element containing the THREEJS canvas. Perhaps this demo that uses the Flip Plugin could help:

Ah sorry my explanation was definitely not clear! I am doing something like the demo you linked, except that the ThreeJS canvas is fixed to the screen size (like in my initial demo above). So the ThreeJS object (but not the canvas) is moved around to match the position of elements in the DOM, using some trigonometry and such to match ThreeJS world positions to DOM positions. I've seen this Flip demo and considered something like that instead, but I initially assumed that moving ThreeJS objects around would be more performant than moving DOM elements around. I'm not sure that assumption was correct though... so I'm considering trying something like this instead. If anyone has any insight on that I'd be happy to hear it! 

 

I did try something inspired by @mvaneijgen's suggestion - collected the object transforms into one timeline, used one ScrollTrigger to seek between the start/end of the zooming in part of the timeline, then used tweenFromTo()to do the resetting which is defined at a specific label on the timeline. The wrapper transforms are still defined individually since that seemed to be working okay. I actually thought this was working before but now it does not seem to be 😓 

 

See the Pen raLOgyr?editors=0010 by crankysparrow (@crankysparrow) on CodePen.

 

Posted

That can also be done with the Flip Plugin, the element having a fixed position, as shown in this demo just in case:

See the Pen PwYapEQ by GreenSock (@GreenSock) on CodePen.

 

Glad that you were able to solve it.

 

Happy Tweening!

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