Jump to content
Search Community

Adding a flip animation to a timeline in react

NickWoodward

Go to solution Solved by Rodrigo,

Recommended Posts

Posted

I've been trying this on and off for a while now, and I can't seem to work out what I'm doing wrong 😕

I'm trying to have 2 timelines - one for scrolling forward, one for backwards - whilst making sure that no new timelines or animations are instantiated.
 

  const forwardAnimationRef = useRef(null);
  const backwardAnimationRef = useRef(null);

initialised once:

  function initTimelines(initialItem, target) {
    forwardAnimationRef.current = gsap
      .timeline({ paused: true })
      .to(".logo", { autoAlpha: 0 })
      .add(() => flipItem(initialItem, target));

    backwardAnimationRef.current = gsap
      .timeline({ paused: true })
      .to(".logo", { autoAlpha: 1 })
      .add(() => flipItem(initialItem, target).reverse());
  }

with each played and killed in a scroll trigger's relevant callbacks:

ScrollTrigger.create({
      ...
      onEnter: () => {
        backwardAnimationRef.current.kill();
        forwardAnimationRef.current.play(0);
      },
      onLeaveBack: () => {
        forwardAnimationRef.current.kill();
        backwardAnimationRef.current.play(0);
      }
    });


but my flipItem logic is obviously wrong.

  function flipItem(element, target) {
    gsap.set(element, { zIndex: 100 });
    const itemState = Flip.getState(element);
    target.appendChild(element);
    const flipTimeline = gsap.timeline({}).add(
      Flip.from(itemState, {
        duration: 0.35,
        ease: "power4.inOut",
        absolute: true,
        simple: true
      })
    );

    return flipTimeline;
  }


Using .add(() => flipItem()) as a callback works forwards, but then suffers problems backwards - presumably because I'm then trying to recalculate the Flip state on each play through the backward animation  - which doesn't make sense. Calculating the state in the initTimeline to make sure that doesn't happen doesn't solve the problem though. Either way this method runs the callback each time the animation plays, creating a new timeline, which is what I'd hoped to avoid.

Trying to add the flip animation directly to the timeline using .add(flipItem()) seems to unfortunately break the flip animation altogether.

I'm not going to lie, I've tried a lot and got myself into a bit of a muddle! 

Nick

**I've a demo of adding the Flip animation directly, but didn't want to clutter the question even more. The demo below is wrong, but demonstrates what I'm trying to achieve much better
 

See the Pen WbrQvXL by nwoodward (@nwoodward) on CodePen.

Posted

Hey Nick!

 

I'm a bit confused here so I'll need some clarification first in order to properly understand the idea here. Are you trying to collapse and expand the logo element to use the entire grid space and then the top left corner based on the grid CSS settings? If that's so perhaps something like this:

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

 

It's far simpler and cleaner. Despite the small overhead of calling and creating the Flip instance on every callback it ensures that Flip will capture the correct state on every call of the function. Sorry for the crude demo but CSS Grid is not something I really excel at.

 

If that is not what you're looking for a bit more clarification could be great.

 

Hopefully this helps

Happy Tweening!

Posted

Hey Rodrigo!
 

57 minutes ago, Rodrigo said:

Are you trying to collapse and expand the logo element to use the entire grid space and then the top left corner based on the grid CSS settings

Not quite - the logo is currently behaving exactly as I'd like, with the forward and backwards animations killed and played by the scroll trigger perfectly - I only included the logo movement to show that the animation of the large grey item to the top left is part of a parent timeline.

So this is working (I was just worried about creating a Flip animation on each call):
 

    forwardAnimationRef.current = gsap
      .timeline({ paused: true })
      .to(".logo", { autoAlpha: 0 })
      .add(() => flipItem(initialItem, target));

But this is not (only the logo is behaving correctly):

    backwardAnimationRef.current = gsap
      .timeline({ paused: true })
      .to(".logo", { autoAlpha: 1 })
      .add(() => flipItem(initialItem, target).reverse());
57 minutes ago, Rodrigo said:

Despite the small overhead of calling and creating the Flip instance on every callback it ensures that Flip will capture the correct state on every call of the function

Ah, that's good to know - So I could just create two separate Flip animations, kill one and animate the other? 


Anyway, here's a gif of what I'm trying to achieve if it helps - scroll down and the logo animates out. The large grey box then flips into the smaller one. That's perfect. But as I scroll back up, only the logo comes into view. The flip isn't reversed. The grey box should flip back to its initial position. 

grid.gif.fb688fa8f2acedc3e638e996915b25a4.gif



Thanks! Really appreciate it, this has completely worn me out 😄

See the Pen WbrQvXL?editors=1011 by nwoodward (@nwoodward) on CodePen.

Posted

Essentially this:

The initial-item

        {/* display overlay */}
        <div
          ref={displayRef}
          className="display absolute inset-[var(--inset)]  overflow-hidden"
        >
          <div className="initial-item flex justify-center items-center h-full bg-slate-400 border-[3px] rounded-xl shadow-xl"></div>
        </div>

into the first grid item when scrolling down.
 

        {/* grid elements */}
        <div
          ref={setItemRefs(0)}
          className="grid-item col-start-1 col-span-2 row-start-1 row-span-2 border"
        >
          {/* empty to start off with */}
        </div>

and then back again into the display when scrolling back up
 

Group 253.png

  • Solution
Posted

Yeah, my best guess is that killing each Timeline over and over is causing this.

 

Also you're calling this function only once when your component is mounted:

initTimelines(initialItem, itemRefs.current[0]);

This is what that function looks like:

function initTimelines(initialItem, target) {
  forwardAnimationRef.current = gsap
    .timeline({ paused: true })
    .to(".logo", { autoAlpha: 0 })
    .add(() => flipItem(initialItem, target));

  backwardAnimationRef.current = gsap
    .timeline({ paused: true })
    .to(".logo", { autoAlpha: 1 })
    .add(() => flipItem(initialItem, target).reverse());
}

So for both Timelines forward and backward you're calling the flipItem function with the same parameters:

function flipItem(element, target) {
  gsap.set(element, { zIndex: 100 });
  const itemState = Flip.getState(element);
  target.appendChild(element);
  const flipTimeline = gsap.timeline({}).add(
    Flip.from(itemState, {
      duration: 0.35,
      ease: "power4.inOut",
      absolute: true,
      simple: true
    })
  );

  return flipTimeline;
}

So basically you're reparenting the element on the same target twice and creating basically the same Flip animation twice, that is why nothing seems to happen when you scroll up, because you're essentially running the same animation when you scroll up and down, see the problem? if you are reparenting the element you need to move it back to the original parent element in order to correctly animate the element to it's original state. I understand the desire to create everything up front in order to optimize resources, but unfortunately more convoluted situations need more complex and not so optimized solutions. In no way I'm saying that the solution I'm proposing is not performant, it seems to work as expected in my demo, but you'll have to test and see how it works in your particular scenario, but the main concept remains, use a call() method in order to call the function that captures the state, then reparents the element to the new target or the original target and then runs the Flip animation. If you can achieve that with just toggling a class (as in the demo) even better, since it's far simpler.

 

Pretty much the same approach as this demo, but instead of a click event is a callback or toggleActions in a ScrollTrigger instance:

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

 

Hopefully this clear things up

Happy Tweening!

  • Like 1
Posted

 

*nevermind!
 

 

Posted

Sorry for the hassle @Rodrigo - you wouldn't believe me if I told you the issue (accidentally eating gherkins), but I've been having brain fog all day trying to work this out and been pretty frustrated. Appreciate I wasn't being particularly smart!

The fix is wine. I'm not even joking. Took me 20 minutes with a few glasses XD

Appreciate the help!

Fixed:

See the Pen VYevMeK?editors=0010 by nwoodward (@nwoodward) on CodePen.

  • Like 1
Posted

Pickles(gherkins <- sorry but not being from the UK never heard of this word 😄) and wine?!  Well perhaps that's the winning mix! 😂

 

Yeah sometimes you need to step away from the problem in order to see it, that's why I do a lot of gardening. I've had more aha moments away from the keyboard and the screens that in front of them, but never with pickles and wine. But is not the first time that pickles or something related to it has solved some issues:

https://www.nhlpa.com/news/1-19661/quirky-drink-becomes-community-connection-for-coleman

 

Is great to hear that you were able to solve it and thanks for sharing your solution with the community! 💚

 

Happy Tweening!

Posted
22 hours ago, Rodrigo said:

but never with pickles and wine.

Ah, no, so it's taken me just over a year of random dizziness, headaches, not being able to take in things (brain fog that people with post viral fatigue complain about) that results in just complete tiredness. Your body kinda gives up and just wants sleep.

And about 2 months ago I narrowed it down to - pickles and rapeseed oil. But I also discovered that drinking wine counteracts the problem slightly 😄

But yeah, you're definitely right on taking a step back - I guess relaxing with alcohol also has that effect!

 

22 hours ago, Rodrigo said:

Is great to hear that you were able to solve it and thanks for sharing your solution with the community!

No worries! You guys have been great!

  • Like 1
Posted

Quick question if you've a sec please Rodrigo - I worked out that in order to pin the grid I had to use a separate wrapper (appRef for the flip trigger, and heroRef for the pinning).

Is it that the onEnter callback automatically runs when pinning? It seems to, but I'm not exactly sure why?

See the Pen ZYQQmXQ?editors=0010 by nwoodward (@nwoodward) on CodePen.

Posted

Hi Nick!

On 9/27/2025 at 12:25 PM, NickWoodward said:

Is it that the onEnter callback automatically runs when pinning? It seems to, but I'm not exactly sure why?

Yeah kind of but not exactly that. This are your ScrollTrigger instances:

ScrollTrigger.create({
  trigger: heroRef.current,
  pin: true,
  pinSpacing: true,
  start: "top top",
  end: "+=20%",
  markers: true
});

ScrollTrigger.create({
  trigger: appRef.current,
  scroller: window,
  start: "top top",
  end: "+=20%",
  markers: { indent: 150 },
  onEnter: () => {
    console.log("on enter");
    const itemState = Flip.getState(initialItem);
    itemRefs.current[0].appendChild(initialItem);
    Flip.from(itemState, {
      duration: 0.6,
      ease: "power4.inOut",
      absolute: true,
      simple: true
    });
  },
  onLeaveBack: () => {
    console.log("on leaveback");
    const itemState = Flip.getState(initialItem);
    displayRef.current.appendChild(initialItem);
    Flip.from(itemState, {
      duration: 0.6,
      ease: "power4.inOut",
      absolute: true,
      simple: true
    });
  }
});

One has an onEnter and onLeaveBack callbacks and the other doesn't. The fact is that both are triggered at the same time, so it might seem like the callback might be executed in the one that is pinning the Hero element, but is the other one actually, since no handler is being added to the onEnter callback in the instance that is pinning the Hero element.

 

I added markers to both instances and added some indent to one in order to be able to differentiate them in the code. If you do that you'll see that both ScrollTrigger instances start and end at the same time, which seems to be what you're looking for.

 

Hopefully this clear things up

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