Search code examples
reactjsgatsbygsapreach-router

Have TimelineLite only play once on Gatsby site


I have a TimelineLite timeline set up on my Gatsby site to animate my hero section when a user navigates to a page. However, if a user clicks a link to the current page i.e. if a user is on the homepage and clicks a link to the homepage, it is reloading the page and triggering the timeline to run again. Is there a way to make sure that my current link will be inactive within Gatsby?

Hero.tsx

import React, { useEffect, useRef, useState } from 'react';

import css from 'classnames';
import { ArrowButton } from 'components/arrow-button/ArrowButton';
import { HeadingReveal } from 'components/heading-reveal/HeadingReveal';
import { gsap, Power2, TimelineLite } from 'gsap';
import { RichText } from 'prismic-reactjs';
import htmlSerializer from 'utils/htmlSerializer';
import { linkResolver } from 'utils/linkResolver';

import s from './Hero.scss';

gsap.registerPlugin(TimelineLite, Power2);

export const Hero = ({ slice }: any) => {
  const linkType = slice.primary.link._linkType;
  const buttonLink =
    linkType === 'Link.document' ? slice.primary.link._meta : slice.primary.link.url;

  const theme = slice.primary.theme;
  const image = slice.primary.image;

  const contentRef = useRef(null);
  const headingRef = useRef(null);
  const copyRef = useRef(null);
  const buttonRef = useRef(null);

  const [tl] = useState(new TimelineLite({ delay: 0.5 }));

  useEffect(() => {
    tl.to(contentRef.current, { css: { visibility: 'visible' }, duration: 0 })
      .from(headingRef.current, { y: 65, ease: Power2.easeOut, duration: 1 })
      .from(copyRef.current, { opacity: 0, y: 20, ease: Power2.easeOut, duration: 1 }, 0.5)
      .from(buttonRef.current, { opacity: 0, y: 10, ease: Power2.easeOut, duration: 1 }, 1);
  }, [tl]);

  return (
    <div
      className={css(s.hero, s[theme])}
      style={{
        background: image ? `url(${image.url})` : 'white',
      }}
    >
      <div className={s.hero__container}>
        <div className={s.content__left} ref={contentRef}>
          <HeadingReveal tag="h1" headingRef={headingRef}>
            {RichText.asText(slice.primary.heading)}
          </HeadingReveal>

          <div className={s.content__copy} ref={copyRef}>
            {RichText.render(slice.primary.copy, linkResolver, htmlSerializer)}
          </div>
          <div className={s.content__button} ref={buttonRef}>
            <ArrowButton href={buttonLink}>{slice.primary.button_label}</ArrowButton>
          </div>
        </div>
  
      </div>
    </div>
  );
};

Solution

  • If you are using the built-in <Link> component to make that navigation that shouldn't happen since the @reach/router doesn't trigger and doesn't re-renders when navigating on the same page. There isn't any rehydration there.

    If you are using an anchor (<a>) you are refreshing the full page so all your components will be triggered again.

    In addition, another workaround that may do the trick in your case is to use a useEffect with empty deps ([]), creating a componentDidMount effect. In that case, since the page is not reloaded, it won't be triggered again:

      const [tl] = useState(new TimelineLite({ delay: 0.5 }));
    
      useEffect(() => {
        tl.to(contentRef.current, { css: { visibility: 'visible' }, duration: 0 })
          .from(headingRef.current, { y: 65, ease: Power2.easeOut, duration: 1 })
          .from(copyRef.current, { opacity: 0, y: 20, ease: Power2.easeOut, duration: 1 }, 0.5)
          .from(buttonRef.current, { opacity: 0, y: 10, ease: Power2.easeOut, duration: 1 }, 1);
        return () => unmountFunction() // unmount to avoid infinite triggers
      }, []);
    

    Note: you may need to make another few adjustments to make the code work since I don't know your requirements. The idea is to remove the dependency of the t1 to bypass the issue.

    To avoid infinite triggers you may also need to unmount the component with return () => unmountFunction().