Framer motion useViewportScroll when element is in viewport
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>
);
}
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 };
}