Framer motion useViewportScroll when element is in viewport

Cole Turner
Cole Turner
2 min read
Cole Turner
Framer motion useViewportScroll when element is in viewport
Blog
 
LIKE
 
LOVE
 
WOW
 
LOL

Framer motion `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.

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;

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.

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 };
}

Checkout the demo here.

 
LIKE
 
LOVE
 
WOW
 
LOL

Keep reading...

Why I Don't Like Take-Home Challenges

4 min read

If you want to work in tech, there's a chance you will encounter a take-home challenge at some point in your career. A take-home challenge is a project that you will build in your free time. In this post, I explain why I don't like take-home challenges.

Standing Out LOUDER in the Technical Interview

5 min read

If you want to stand out in the technical interview, you need to demonstrate not only your technical skills but also your communication and collaboration skills. You need to think LOUDER.

See everything