Intersection Observer API with react - reactjs

I am trying to create a navigation tracker on a page where a user scrolls with the intersection observer API and a tracker shows what section on the page in react like something shown in the website with this link https://olaolu.dev/ with the boxes that changes on scroll (scroll indicators), I already created a code sandbox. anyone who can assist me would mean a lot. been getting errors here and there don't even know what to do again, Link to code sandbox below
https://codesandbox.io/s/tender-oskar-rvbz5?file=/src/App.js

I only used react-intersection-observer with framer motion before, I added a ref to my h1 so the observer knows if it's inView or not.
controls.start will trigger if inView is true.
import { useInView } from "react-intersection-observer";
const scrollVariant = {
hidden: {
opacity: 0,
x: 0,
transition: {
duration: 1,
},
},
visible: {
opacity: 1,
x: 250,
transition: {
duration: 1,
},
},
};
export default function Home() {
const controls = useAnimation();
const { ref, inView } = useInView({
triggerOnce: false,
});
React.useEffect(() => {
if (inView) {
controls.start("visible");
}
if (!inView) {
controls.start("hidden");
}
}, [controls, inView]);
return (
<>
<motion.h1
variants={scrollVariant}
initial="hidden"
animate={controls}
ref={ref}
>
</>
);
}

Related

Page transitions in React Spring and Next JS

So I'm trying to create a simple page transition between pages in next.js using react-spring, however I can't get the unmounting component to fade out. Instead it just snaps out of the screen, while the new one fades in:
The screen then loads itself underneath, I'm really struggling to work out what's going on?
I've tried adding absolute positions to the from and/or the leave keys, but to no avail
//_app.js
import "../styles/globals.css";
import { useTransition, animated } from "react-spring";
import { useRouter } from "next/router";
function MyApp({ Component, pageProps }) {
const router = useRouter();
const transition = useTransition(router, {
key: router.pathname,
from: { opacity: 0},
enter: { opacity: 1 },
leave: { opacity: 0},
config: { duration: 1000 },
// reset:true,
});
return transition((style, item) => {
return (
<animated.div style={style}>
<Component {...pageProps} />
</animated.div>
);
});
}
export default MyApp;
Any help would be great! Thank you
I think the reason this is going wrong for you is that you're using the Component from my app to render, but passing in the useRouter state to the useTransition hook, resulting in them being disconnected.
I got it to work using an additional array:
function MyApp({ Component, pageProps }) {
const router = useRouter();
// initial state
const [compoentArray, setComponentArray] = useState([
<Component key={router.pathname} {...pageProps} />,
]);
const transitions = useTransition(compoentArray, {
from: { opacity: 0 },
enter: [{ opacity: 1 }],
leave: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0,
},
delay: 200,
});
// Updates the array when needed. Avoids rerenders
useEffect(() => {
if (compoentArray[0].key === router.pathname) {
return;
}
setComponentArray([<Component key={router.pathname} {...pageProps} />]);
}, [Component, pageProps, router, compoentArray]);
return (
<div>
{transitions((style, item) => {
// Render items managed by react-spring
return <animated.div style={style}>{item}</animated.div>;
})}
</div>
);
}

Framer animation using react intersection observer. Need multiple animations but get only one

I'm trying to assign the same animation to multiple instances of a component, using Framer Motion and the react-intersection-observer package
import { useEffect, useRef, useCallback } from "react";
import { motion, useAnimation } from "framer-motion";
import { useInView } from "react-intersection-observer";
const levels = [
{
title: "GROUP LESSONS",
description:
"Lorem ipsum",
},
{
title: "WORKSHOPS",
description:
"Lorem ipsum",
},
];
const container = {
show: {
transition: {
staggerChildren: 0.2,
},
},
};
const item = {
hidden: { opacity: 0, x: 200 },
show: {
opacity: 1,
x: 0,
transition: {
ease: [0.6, 0.01, -0.05, 0.95],
duration: 1.6,
},
},
};
const Levels = () => {
const animation = useAnimation();
const [levelRef, inView] = useInView({
triggerOnce: true,
});
useEffect(() => {
if (inView) {
animation.start("show");
}
}, [animation, inView]);
return (
<LevelsContainer>
{levels.map((level, index) => {
return (
<LevelsWrapper
key={index}
ref={levelRef}
animate={animation}
initial="hidden"
variants={container}
>
<Level variants={item}>
<Title>{level.title}</Title>
<Description>{level.description}</Description>
</Level>
</LevelsWrapper>
);
})}
</LevelsContainer>
);
};
This results in the animation loading only when scrolling to the last LevelWrapper component. Then "inView" is set to true and all the components animate at the same time. In the react-intersection-observer package documentation, there's some info about wrapping multiple ref assignments in a single useCallback, so I've tried that:
const animation = useAnimation();
const ref = useRef();
const [levelRef, inView] = useInView({
triggerOnce: true,
});
const setRefs = useCallback(
(node) => {
ref.current = node;
levelRef(node);
},
[levelRef]
);
useEffect(() => {
if (inView) {
animation.start("show");
}
}, [animation, inView]);
return (
<LevelsContainer>
{levels.map((level, index) => {
return (
<LevelsWrapper
key={index}
ref={setRefs}
animate={animation}
initial="hidden"
variants={container}
>
<Level variants={item}>
<Title>{level.title}</Title>
<Description>{level.description}</Description>
</Level>
</LevelsWrapper>
);
})}
</LevelsContainer>
);
But the animations still don't trigger individually for each LevelWrapper component. What's happening?
No idea why the code in the question doesn't work but I found the final result can be reached without using neither useEffect, useRef, useCallback, , useAnimation or useInView.
In the Framer Motion documentation:
Motion extends the basic set of event listeners provided by React with a simple yet powerful set of UI gesture recognisers.
It currently has support for hover, tap, pan, viewport and drag
gesture detection. Each gesture has a series of event listeners that
you can attach to your motion component.
Then applied whats explained here: https://www.framer.com/docs/gestures/#viewport-options
<LevelsWrapper
key={index}
initial="hidden"
whileInView="show"
variants={container}
viewport={{ once: true, amount: 0.8, margin: "200px" }}
>

Framer Motion dynamic variants don't work when modifying initial properties

According to the docs I can make variant properties dynamic: https://www.framer.com/docs/animation/##dynamic-variants.
But this doesn't work when I try to make the initial properties dynamic.
For example:
import React, { useState, useEffect } from "react";
import { motion, useAnimation } from "framer-motion";
//make div appear from either bottom or right, depending on "origin" custom prop
const variant = {
hidden: (origin) =>
origin === "bottom"
? { x: 0, y: 200, opacity: 0 }
: { x: 200, y: 0, opacity: 0 },
visible: { x: 0, y: 0, opacity: 1, transition: { duration: 1 } },
};
function App() {
const [origin, setOrigin] = useState("bottom");
const controls = useAnimation();
//after 2 secs make origin "right"
useEffect(() => {
setTimeout(() => {
setOrigin("right");
}, 2000);
}, []);
//after 4 secs start the animation
useEffect(() => {
setTimeout(() => {
controls.start("visible");
}, 4000);
}, [controls]);
return (
<motion.div
style={{ width: 100, height: 50, background: "red" }}
variants={variant}
initial="hidden"
animate={controls}
custom={origin}
/>
);
}
export default App;
Here I made a dynamic variant to make a div appear from either the right or bottom, which I can control from a custom prop. Initially this custom prop is set to "bottom". After 2 secs, this is changed to "right". When I start the animation after 4 secs, I expect the div to appear from the right but it still appears from the bottom:
This is because the component is already rendered and is still the same component even if the origin prop being passed to the component has changed.
You can do two things:
Use a isVisible state variable where the render method will observe for changes and render the component when it becomes true.
function App() {
const [isVisible, setIsVisible] = useState(false);
...
//after 4 secs start the animation
useEffect(() => {
setTimeout(() => {
setIsVisible(true);
controls.start("visible");
}, 4000);
}, [controls]);
return (
isVisible && (
<motion.div
...
/>
)
);
}
DEMO
Add a key prop to the component with the origin value so that when the value changes, React will re-render the component.
function App() {
...
return (
<motion.div
key={origin}
...
/>
);
}
DEMO
2nd option may be your preferred choice if you need to toggle between the origin.

Have TimelineLite only play once on Gatsby site

I have a TimelineLite timeline set up on my Gatsby site to animate my hero section when a user navigates to a page. However, if a user clicks a link to the current page i.e. if a user is on the homepage and clicks a link to the homepage, it is reloading the page and triggering the timeline to run again. Is there a way to make sure that my current link will be inactive within Gatsby?
Hero.tsx
import React, { useEffect, useRef, useState } from 'react';
import css from 'classnames';
import { ArrowButton } from 'components/arrow-button/ArrowButton';
import { HeadingReveal } from 'components/heading-reveal/HeadingReveal';
import { gsap, Power2, TimelineLite } from 'gsap';
import { RichText } from 'prismic-reactjs';
import htmlSerializer from 'utils/htmlSerializer';
import { linkResolver } from 'utils/linkResolver';
import s from './Hero.scss';
gsap.registerPlugin(TimelineLite, Power2);
export const Hero = ({ slice }: any) => {
const linkType = slice.primary.link._linkType;
const buttonLink =
linkType === 'Link.document' ? slice.primary.link._meta : slice.primary.link.url;
const theme = slice.primary.theme;
const image = slice.primary.image;
const contentRef = useRef(null);
const headingRef = useRef(null);
const copyRef = useRef(null);
const buttonRef = useRef(null);
const [tl] = useState(new TimelineLite({ delay: 0.5 }));
useEffect(() => {
tl.to(contentRef.current, { css: { visibility: 'visible' }, duration: 0 })
.from(headingRef.current, { y: 65, ease: Power2.easeOut, duration: 1 })
.from(copyRef.current, { opacity: 0, y: 20, ease: Power2.easeOut, duration: 1 }, 0.5)
.from(buttonRef.current, { opacity: 0, y: 10, ease: Power2.easeOut, duration: 1 }, 1);
}, [tl]);
return (
<div
className={css(s.hero, s[theme])}
style={{
background: image ? `url(${image.url})` : 'white',
}}
>
<div className={s.hero__container}>
<div className={s.content__left} ref={contentRef}>
<HeadingReveal tag="h1" headingRef={headingRef}>
{RichText.asText(slice.primary.heading)}
</HeadingReveal>
<div className={s.content__copy} ref={copyRef}>
{RichText.render(slice.primary.copy, linkResolver, htmlSerializer)}
</div>
<div className={s.content__button} ref={buttonRef}>
<ArrowButton href={buttonLink}>{slice.primary.button_label}</ArrowButton>
</div>
</div>
</div>
</div>
);
};
If you are using the built-in <Link> component to make that navigation that shouldn't happen since the #reach/router doesn't trigger and doesn't re-renders when navigating on the same page. There isn't any rehydration there.
If you are using an anchor (<a>) you are refreshing the full page so all your components will be triggered again.
In addition, another workaround that may do the trick in your case is to use a useEffect with empty deps ([]), creating a componentDidMount effect. In that case, since the page is not reloaded, it won't be triggered again:
const [tl] = useState(new TimelineLite({ delay: 0.5 }));
useEffect(() => {
tl.to(contentRef.current, { css: { visibility: 'visible' }, duration: 0 })
.from(headingRef.current, { y: 65, ease: Power2.easeOut, duration: 1 })
.from(copyRef.current, { opacity: 0, y: 20, ease: Power2.easeOut, duration: 1 }, 0.5)
.from(buttonRef.current, { opacity: 0, y: 10, ease: Power2.easeOut, duration: 1 }, 1);
return () => unmountFunction() // unmount to avoid infinite triggers
}, []);
Note: you may need to make another few adjustments to make the code work since I don't know your requirements. The idea is to remove the dependency of the t1 to bypass the issue.
To avoid infinite triggers you may also need to unmount the component with return () => unmountFunction().

Framer: Check if element is into viewport

While using Framer Motion API to create interaction and animations on my site, I can not find how to use it in order to trigger an animation when something is on the screen.
For example, this SVG draws correctly, but Framer does not wait for the element to be on the viewport and triggers it right after loading site:
import React, { Component } from 'react'
import { motion } from "framer-motion";
class IsometricScreen extends Component {
constructor() {
super()
this.icon = {
hidden: { pathLength: 0 },
visible: { pathLength: 1 }
}
}
render() {
return (
<motion.svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 1000" className="svg-mobile">
<motion.path
d="M418,988.93H82c-39.76,0-72-32.24-72-72V83.07c0-39.76,32.24-72,72-72h336c39.76,0,72,32.24,72,72v833.86
C490,956.69,457.76,988.93,418,988.93z"
variants={this.icon}
initial="hidden"
animate="visible"
transition={{
default: { duration: 2, ease: "easeInOut" }
}}
/>
</motion.svg>
)
}
}
export default IsometricScreen
Does Framer have a viewport detection triggerer to be implemented here?
Alternatively, you can use Intersection Observer, blends pretty well with React and framer motion.
import { useInView } from "react-intersection-observer"; // 1.9K gzipped
import { motion, useAnimation } from "framer-motion";
const Component = () => {
const animation = useAnimation();
const [ref, inView, entry] = useInView({ threshold: 0.1 });
useEffect(() => {
if (inView) {
animation.start("visible");
} else {
animation.start("hidden");
}
}, [animation, inView]);
const variants = {
visible: {
y: 0,
opacity: 1,
transition: { duration: 0.5, delayChilden: 0.2, staggerChildren: 0.1 },
},
hidden: {
y: enter,
opacity: 0,
},
}
return (
<motion.div
ref={ref}
animate={animation}
initial="hidden"
variants={{variants}}
/>
);
}
You can also refine your animation by looking at entry object (entering from top or bottom, etc)
framer-motion has built-in support for this use case since version 5.3.
Here's a CodeSandbox demonstrating the pattern: 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>
I have finally solved this with a tiny functional component:
function inViewport() {
const isInViewport = el => {
const rect = el.getBoundingClientRect()
const vertInView = (rect.top <= window.innerHeight) && ((rect.top + rect.height) >= 0)
const horInView = (rect.left <= window.innerWidth) && ((rect.left + rect.width) >= 0)
return (vertInView && horInView)
}
this.elms = document.querySelectorAll('.showOnScreen')
window.addEventListener("scroll", () => {
this.elms.forEach(elm => isInViewport(elm) ? elm.classList.add('visible') : elm.classList.remove('visible'))
})
}
export default inViewport

Resources