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 };
}
@donnersvensson
Copy link

@coleturner First of all: Thanks for the beautiful hook! Most of it is working amazingly well.🥳 What I can´t figure out: If the component, that I use the hook on, is followed by more content, it seems to break the percentage as the total window size increases.

Do you have an idea, how to fix this?

@MijaelFV
Copy link

Thanks for creating this! :D. I searched a lot and i almost gave up but then i found your solution and now i'm able to do the animations that i want in my Portfolio.

@magalhaespaulo
Copy link

@coleturner First of all: Thanks for the beautiful hook! Most of it is working amazingly well.🥳 What I can´t figure out: If the component, that I use the hook on, is followed by more content, it seems to break the percentage as the total window size increases.

Do you have an idea, how to fix this?

First, thanks for the idea @coleturner.

I took the liberty to improve for my need:

  1. TypeScript version
  2. Return Array instead of Object for ease of use, like:
const [refFoo, startFoo, endFoo] = useRefScrollProgress()
const [refBar, startBar, endBar] = useRefScrollProgress()

instead of:

const { ref: refFoo, start: starFoo, end: endFoo } = useRefScrollProgress()
const { ref: refBar, start: startBar, end: endBar } = useRefScrollProgress()
  1. Answering our friend's question: @donnersvensson use
const [refExample, startExample, endExample] = useRefScrollProgress(variableToWatchHere)

The hook:

import type { MutableRefObject } from 'react'
import { useLayoutEffect, useRef, useState } from 'react'

export const useRefScrollProgress = (watch?: unknown): [MutableRefObject<HTMLDivElement>, number, number] => {
  const ref = useRef<HTMLDivElement>(null!)
  const [start, setStart] = useState(0)
  const [end, setEnd] = useState(0)

  useLayoutEffect(() => {
    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)
  }, [watch])

  return [ref, start, end]
}

@kinafu
Copy link

kinafu commented May 18, 2022

@coleturner First of all: Thanks for the beautiful hook! Most of it is working amazingly well.🥳 What I can´t figure out: If the component, that I use the hook on, is followed by more content, it seems to break the percentage as the total window size increases.

Do you have an idea, how to fix this?

Thank you, @coleturner for sharing your solution!

So after I tried the code from the gist by @coleturner because I do not know what to do with the TS "module" version by @magalhaespaulo and thus was not able to try it out I also encountered the calculation of the heights in the version by @coleturner to be slightly off.

The solution for me was to go for the offset values without calculating the relative offset (percentage) and instead pass the absolute values.
I am using the values in conjunction with framer-motion's useTransform.

I adapted the solution for my case, where I want to start the animation when my ref's top passes the viewport's bottom.
So I do:

const offsetTop = rect.top + scrollTop - window.innerHeight

@magalhaespaulo
Copy link

magalhaespaulo commented May 18, 2022

I adapted the solution for my case, where I want to start the animation when my ref's top passes the viewport's bottom. So I do:

const offsetTop = rect.top + scrollTop - window.innerHeight

I started using absolute too

@coleturner
Copy link
Author

Thanks all for the discussion. I'm sorry that I have no bandwidth to support this, however, if someone wants to fork and maintain a more official library, please be encouraged to take over maintenance (with credit).

@leifarriens
Copy link

leifarriens commented Jun 25, 2022

I did some tweaking to the original hook trying to fix the problem @donnersvensson was mentioning and came to the following solution which is also inspired by @magalhaespaulo hook.

The Idea is to start the animation when the ref starts entering the viewport and to end when the ref has fully left the screen:

export function useRefScrollProgress(watch?: unknown) {
 const ref = useRef<HTMLDivElement>(null);
 const [start, setStart] = useState(0);
 const [end, setEnd] = useState(0);

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

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

   const documentHeight = document.body.clientHeight - window.innerHeight;

   setStart(offsetTop / documentHeight);
   setEnd((offsetTop + window.innerHeight + rect.height) / documentHeight);
 }, [watch]);

 return { ref, start, end };
}

Start animation when ref enters viewport:

- const offsetTop = rect.top + scrollTop;
+ const offsetTop = rect.top + scrollTop - window.innerHeight;

Fix the percentage breaking when adding more elements to the document by calculating the "correct" document height:

+ const documentHeightOffset = document.body.clientHeight - window.innerHeight;

-  setStart(offsetTop / document.body.clientHeight);
+ setStart(offsetTop / documentHeightOffset);

End animation when ref has completely left the viewport:

- setEnd((offsetTop + rect.height) / document.body.clientHeight);
+ setEnd((offsetTop + window.innerHeight + rect.height) / documentHeightOffset);

I am propably going to fork this hook into a small library in the future. So thank you @coleturner for the inspiring idea to solve viewport triggered animations with framer in an easy way. I will share the repo when its done.

Edit:
Repo Link

@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