Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 25 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save coleturner/34396fb826c12fbd88d6591173d178c2 to your computer and use it in GitHub Desktop.
Save coleturner/34396fb826c12fbd88d6591173d178c2 to your computer and use it in GitHub Desktop.
(Framer Motion): useViewportScroll with element container

Demo

Context

useViewportScroll is a great way to create a parallax effect as the page scrolls. In some cases however, we only want to scroll when an element is in the viewport area.

So for example, if we have a "landscape" scene, and want to animate the Sun object only when it's in view, we start with our useViewportScroll implementation:

function Sun(props) {
  const { scrollY, scrollYProgress } = useViewportScroll();

  // useTransform(motionValue, from, to);
  const scale = useTransform(scrollYProgress, [0, 1], [0.5, 1]);

  return (
      <motion.div
        style={{
          scale,
        }}
      >
        <SunSVG />
      </motion.div>
  );
}

With the example above, the div wrapping the <SunSVG /> will scale from 0.5 to 1 as the document scrolls from the start of the page to the end.

To start this transform when the object comes into view, the from=[0, 1] will need to channge to reflect where the Sun component is rendered in the page. You can approximate this and hardcode the value, but the transform will become out of date when the page changes.

Deriving the from attribute

To determine where the Sun renders, we will need to know its position in the page. Using getBoundingClientRect() we can calculate the position on the page and how much space it occupies:

    // Get the distance from the start of the page to the element start
    const rect = element.getBoundingClientRect();
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    const offsetStart = rect.top + scrollTop;

And then the distance from the start of the page to the end of the Sun's node:

  // Get the distance from the start of the page to the element end
  const offsetEnd = (offsetTop + rect.height);

With these values, we can now determine what is the percentage of the total page scroll:

  const elementScrollStart = offsetStart / document.body.clientHeight;
  const elementScrollEnd = offsetEnd / document.body.clientHeight;

Adding to your React component

Going back to the example above, we will want to embed this logic in our component, using useLayoutEffect to calculate the position after the initial mount. We also need to add useRef so that we can access the element's DOM node.

function Sun(props) {
+  const ref = useRef();
  const { scrollY, scrollYProgress } = useViewportScroll();

  // useTransform(motionValue, from, to);
  const scale = useTransform(scrollYProgress, [0, 1], [0.5, 1]);
  
+  useLayoutEffect(() => {
+    // Get the distance from the start of the page to the element start
+    const rect = ref.current.getBoundingClientRect();
+    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
+    
+    const offsetStart = rect.top + scrollTop;
+    const offsetEnd = (offsetTop + rect.height);
+    
+    const elementScrollStart = offsetStart / document.body.clientHeight;
+    const elementScrollEnd = offsetEnd / document.body.clientHeight;
+    
+    // to be continued
+  });

  // Adding a new div as an "anchor" in case motion.div
  // has other transforms that affect its position on the page
  return (
+    <div ref={ref}>
      <motion.div
        style={{
          scale,
        }}
      >
        <SunSVG />
      </motion.div>
+    </div>
  );
}

Lastly we need to add useState to store these values, re-render the component and update the motion transform with the new percentages.

function Sun(props) {
  const ref = useRef();
  
+  // Stores the start and end scrolling position for our container
+  const [scrollPercentageStart, setScrollPercentageStart] = useState(null);
+  const [scrollPercentageEnd, setScrollPercentageEnd] = useState(null);
  
  const { scrollY, scrollYProgress } = useViewportScroll();

  // Use the container's start/end position percentage
-  const scale = useTransform(scrollYProgress, [0, 1], [0.5, 1]);
+  const scale = useTransform(scrollYProgress, [scrollPercentageStart, scrollPercentageEnd], [0.5, 1]);
  
  useLayoutEffect(() => {
    // Get the distance from the start of the page to the element start
    const rect = ref.current.getBoundingClientRect();
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    
    const offsetStart = rect.top + scrollTop;
    const offsetEnd = (offsetTop + rect.height);
    
    const elementScrollStart = offsetStart / document.body.clientHeight;
    const elementScrollEnd = offsetEnd / document.body.clientHeight;
+    
+    setScrollPercentageStart(elementScrollStart);
+    setScrollPercentageEnd(elementScrollEnd);
  });

  return (
    <div ref={ref}>
      <motion.div
        style={{
          scale,
        }}
      >
        <SunSVG />
      </motion.div>
     </div>
  );
}

🎉 Your component will now start its transition when the start of the container has reached the top of the viewport.

"There's a hook for that"

All of the logic above can also be combined into a React hook, for those who prefer shortcuts :)

/*
  Takes an optional component ref (or returns a new one)
  and returns the ref, the scroll `start` and `end` percentages
  that are relative to the total document progress.
*/

function useRefScrollProgress(inputRef) {
  const ref = inputRef || useRef();

  const [start, setStart] = useState(null);
  const [end, setEnd] = useState(null);

  useLayoutEffect(() => {
    if (!ref.current) {
      return;
    }

    const rect = ref.current.getBoundingClientRect();
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    const offsetTop = rect.top + scrollTop;

    setStart(offsetTop / document.body.clientHeight);
    setEnd((offsetTop + rect.height) / document.body.clientHeight);
  });

  return { ref, start, end };
}
@ChazUK
Copy link

ChazUK commented Aug 23, 2022

This was a life saver. The Framer Motion documentation and examples are so sparse that it's impossible to dig into the API properly.

@coleturner
Copy link
Author

coleturner commented Oct 11, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment