'React.Js + Framer Motion animate only on initial page load

I am working on a React project where I have components animate in when they scroll in to view. I am using Framer Motion. How can I make it so the animation only triggers the first time you scroll by the component?

Right now, if I scroll down the page the animations work as expected. However, if I refresh or leave the page and come back the animations will fire again. Scrolling to the middle of the page, refreshing, and then scrolling back up will fire animations on components that were scrolled by before.

I understand this is the default behavior of Framer Motion going from initial value to animate value as components remount. I am looking to prevent this behavior on components that where in the users viewport before.

Sample code for one of the components is posted below. Any help is appreciated.

const Banner = ({ title, body, buttonStyle, buttonText, image, switchSide, link }) => {
  const { ref, inView } = useInView({
    threshold: .8
  })
  return (
    <motion.div className="banner" 
      ref={ref}
      initial={{  opacity: 0 }}
      animate={ inView ? {  opacity: 1 } : ''}
      transition={{ duration: .75 }}
    >
      <div className={`container ${switchSide ? 'banner-switch': ''}`}>
        <div className="side-a">
          <img src={ image } />
        </div>
        <div className="side-b">
          <h2>{ title }</h2>
          <p>{ body }</p>
          {
            buttonText
              ? <Button buttonStyle={buttonStyle} link={link} justify="flex-start">{ buttonText }</Button>
              : ''
          }
        </div>
      </div>
    </motion.div>
  )
}
export default Banner


Solution 1:[1]

I was faced with a similar problem lately. I was implementing intro animation and did not want it to trigger on every page refresh, so I have made a custom hook which saves a timestamp in local storage and on each page refresh compares saved time with stored timestamp and fires when the time has passed and stores a new value there. If you want to play it only once you could simply implement customize my code to store boolean and you’re good to go.

My custom hook

import {useEffect} from 'react';

const useIntro = () => {

const storage = window.localStorage;
const currTimestamp = Date.now();
const timestamp = JSON.parse(storage.getItem('timestamp') || '1000');

const timeLimit = 3 * 60 * 60 * 1000; // 3 hours

const hasTimePassed = currTimestamp - timestamp > timeLimit;

useEffect(() => {
    hasTimePassed ? 
        storage.setItem('timestamp', currTimestamp.toString()) 
        : 
        storage.setItem('timestamp', timestamp.toString());
}, []);

return hasTimePassed;
};

export default useIntro;

You would need to make this simple change in your code

const Banner = ({ title, body, buttonStyle, buttonText, image, switchSide, link }) => {
    const showAnimation = useIntro();


  const { ref, inView } = useInView({
    threshold: .8
  })
  return (
    <motion.div className="banner" 
      ref={ref}
      initial={{  opacity: 0 }}
      animate={ inView && showAnimation ? {  opacity: 1 } : ''}
      transition={{ duration: .75 }}
    >
      <div className={`container ${switchSide ? 'banner-switch': ''}`}>
        <div className="side-a">
          <img src={ image } />
        </div>
        <div className="side-b">
          <h2>{ title }</h2>
          <p>{ body }</p>
          {
            buttonText
              ? <Button buttonStyle={buttonStyle} link={link} justify="flex-start">{ buttonText }</Button>
              : ''
          }
        </div>
      </div>
    </motion.div>
  )
}
export default Banner

Hope this is what you were after.

Solution 2:[2]

I made a small alteration to your hook so that it can track separate pages. Lets say you have visited the home page and animations have fired there, but you still want animations to fire on other pages.

import {useEffect} from 'react';
import { useLocation } from 'react-router-dom'

export const useIntro = () => {

const location = useLocation()
const urlPath = location.pathname
const storage = window.localStorage;
const currTimestamp = Date.now();
const timestamp = JSON.parse(storage.getItem(`timestamp${urlPath}`) || '1000');

const timeLimit = 3 * 60 * 60 * 1000; // 3 hours

const hasTimePassed = currTimestamp - timestamp > timeLimit;

useEffect(() => {
    hasTimePassed ? 
        storage.setItem(`timestamp${urlPath}`, currTimestamp.toString()) 
        : 
        storage.setItem(`timestamp${urlPath}`, timestamp.toString());
}, []);

return hasTimePassed;
};

export default useIntro;

Solution 3:[3]

There's an API for that - the intersection observer API. And there's a React hook for using it, too - react-intersection-observer. I'm using that in a project right now - here I've extracted it as a custom hook

const useHasBeenViewed = () => {
  const [ref, inView] = useInView();
  const prevInView = useRef(false);
  const hasBeenViewed = prevInView.current || inView;
  useEffect(() => {
    prevInView.current = inView;
  });
  
  return [hasBeenViewed, ref];
}

And in use

const App = () => {
  const [hasBeenViewed, ref] = useHasBeenViewed();
  return (
    <motion.div animate={{opacity: hasBeenViewed ? 1 : 0}} ref={ref}>
      {hasBeenViewed}
    </div>
  );
}

The timestamp answers seem like an inelegant workaround to me when the intersection observer API is just for this.

Solution 4:[4]

Framer motion now has built in support for this now. Checkout this link:

https://codesandbox.io/s/framer-motion-animate-in-view-5-3-94j13

Relevant code:

function FadeInWhenVisible({ children }) {
  return (
    <motion.div
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true }}
      transition={{ duration: 0.3 }}
      variants={{
        visible: { opacity: 1, scale: 1 },
        hidden: { opacity: 0, scale: 0 }
      }}
    >
      {children}
    </motion.div>
  );
}

Usage:

<FadeInWhenVisible>
  <Box />
</FadeInWhenVisible>

This makes sure that the animation occurs only once when the element comes into the viewport. We don't the react-intersection-observer for checking if an element is in the viewport anymore!

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Kryptikk21
Solution 3
Solution 4 Mathew Dony