React spring useTransition state updates modifying exiting component - reactjs

I'm using react-spring to animate transitions in a list of text. My animation currently looks like this:
As you can see, the text in the exiting component is also updating, when I would like it to stay the same.
Here's what I am trying:
import {useTransition, animated} from 'react-spring'
import React from 'react'
function useInterval(callback, delay) {
const savedCallback = React.useRef();
// Remember the latest callback.
React.useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
React.useEffect(() => {
let id = setInterval(() => {
savedCallback.current();
}, delay);
return () => clearInterval(id);
}, [delay]);
}
function App() {
const [copyIndex, setCopyIndex] = React.useState(0);
const transitions = useTransition(copyIndex, null, {
from: { opacity: 0, transform: 'translate3d(0,100%,0)', position: 'absolute'},
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
leave: { opacity: 0, transform: 'translate3d(0,-50%,0)' }
});
const copyList = ["hello", "world", "cats", "dogs"];
useInterval(() => {
setCopyIndex((copyIndex + 1) % copyList.length);
console.log(`new copy index was ${copyIndex}`)
}, 2000);
return (
transitions.map(({ item, props }) => (
<animated.div style={props} key={item}>{copyList[copyIndex]}</animated.div>
))
)
}
export default App;
Any ideas on how to get this to work as desired? Thank you so much!

Let the transition to manage your elements. Use the element instead of the index. Something like this:
const transitions = useTransition(copyList[copyIndex], item => item, {
...
transitions.map(({ item, props }) => (
<animated.div style={props} key={item}>{item}</animated.div>
))

Related

framer motion : different animation for mobile and desktop

Trying to make a different animation when on mobile or on desktop, to do so I'm using a useMediaQueryHoook and changing the variant in function of it. But the init animation seems to always assume that I'm on desktop. I guess because the useMediaQueryHook doesn't have time to actualise before the anim is launch. How can I deal with that issue ?
Btw I'm on nextjs :)
Here is my code :
const onMobile = useMediaQuery("(min-width : 428px)");
const wishCardVariant = {
hidden: (onMobile) => ({
opacity: 0,
y: onMobile ? "100%" : 0,
x: onMobile ? 0 : "100%",
transition,
}),
visible: (onMobile) => ({
opacity: 1,
x: 0,
y: 0,
}),
};
here is the hook :
import react, { useState, useEffect } from "react";
export default function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => {
setMatches(media.matches);
};
media.addListener(listener);
return () => media.removeListener(listener);
}, [matches, query]);
return matches;
}

Slideshow effect with Framer Motion

When some prop in my component changes in framer motion, I would like to fade that property out, then fade the "new" property value in?
Here's the timeline of the animation as I imagine it:
0: Prop Value Changes, old value start to fade out
.5: New value visible
1: New value finishes fading in
But the only way I see to do this with framer is to use Timeouts. Is there some way other than using timeouts to achieve this effect?
Codesandbox
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import "./styles.css";
export default function App() {
const [seconds, setSeconds] = useState(0);
const [anim, setAnim] = useState("in");
useEffect(() => {
const interval = setInterval(() => {
setSeconds((seconds) => seconds + 1);
setAnim("in");
setTimeout(() => {
setAnim("out");
}, 500);
}, 1000);
return () => clearInterval(interval);
}, []);
const variants = {
out: {
opacity: 0
},
in: {
opacity: 1,
transition: {
duration: 0.5
}
}
};
return (
<motion.div
animate={anim}
variants={variants}
className="App"
style={{ fontSize: 100 }}
>
{seconds}
</motion.div>
);
}
You can do this with AnimatePresence.
Wrap your motion.div with an AnimatePresence tag, and use the seconds as a unique key for your div. The changing key will trigger AnimatePresence to animate the div in and out each time it changes (because new key means it's a different element).
To get this to work, you'll need to define your animations on the initial, animate, and exit props.
You'll also want to be sure to set the exitBeforeEnter prop on AnimatePresence so the fade out animation completes before the fade in starts.
Sandbox Example
export default function App() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds((seconds) => seconds + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<AnimatePresence exitBeforeEnter>
<motion.div
initial={{opacity:0}}
animate = {{opacity: 1, transition:{duration: 0.5}}}
exit={{opacity: 0 }}
className="App"
style={{ fontSize: 100 }}
key={seconds}
>
{seconds}
</motion.div>
</AnimatePresence>
);
}

Why are my dimensions not updating on resize when using React Hooks

My dimensions are not updating whenever the window is resized. In the code below you can see the window.innerHeight is updated, but the dimensions are not. I am probably missing something but I have not figured it out yet.
// Navbar.components.ts:
export const sidebar = () => {
let height;
let width;
if (typeof window !== `undefined`) {
height = window.innerHeight
width = window.innerWidth
}
const [dimensions, setDimensions] = useState({
windowHeight: height,
windowWidth: width,
})
useEffect(() => {
const debouncedHandleResize = debounce(function handleResize() {
setDimensions({
windowHeight: window.innerHeight,
windowWidth: window.innerWidth,
});
// Logging window.innerHeight gives the current height,
// Logging dimensions.windowHeight gives the initial height
console.log(window.innerHeight, " . ", dimensions.windowHeight)
}, 100);
window.addEventListener(`resize`, debouncedHandleResize)
return () => window.removeEventListener('resize', debouncedHandleResize)
}, [])
return {
open: () => ({
clipPath: `circle(${dimensions.windowHeight * 2 + 200}px at 40px 40px)`,
transition: {
type: "spring",
stiffness: 20,
restDelta: 2
}
}),
closed: () => ({
clipPath: `circle(30px at ${300 - 40}px ${dimensions.windowHeight - 45}px)`,
transition: {
delay: 0.2,
type: "spring",
stiffness: 400,
damping: 40
}
})
}
}
And I use the sidebar like this:
// Navbar.tsx
const Navbar: React.FC<NavbarProps> = () => {
...
return {
...
<MobileNavBackground variants={sidebar()} />
...
}
}
Here is an example of the logs that are returned when resizing the window:
Update 1
#sygmus1897
Code changed to this:
// Navbar.tsx:
const Navbar: React.FC<NavbarProps> = () => {
const [windowWidth, windowHeight] = getDimensions();
useEffect(() => {
}, [windowWidth, windowHeight])
return (
...
<MobileNavWrapper
initial={false}
animate={menuIsOpen ? "open" : "closed"}
custom={height}
ref={ref}
menuIsOpen={menuIsOpen}
>
<MobileNavBackground variants={sidebar} custom={windowHeight} />
<MobileNav menuIsOpen={menuIsOpen} toggleMenu={toggleMenu} />
<MenuToggle toggle={() => toggleMenu()} />
</MobileNavWrapper>
)
}
// getDimensions()
export const getDimensions = () => {
const [dimension, setDimension] = useState([window.innerWidth, window.innerHeight]);
useEffect(() => {
window.addEventListener("resize", () => {
setDimension([window.innerWidth, window.innerHeight])
});
return () => {
window.removeEventListener("resize", () => {
setDimension([window.innerWidth, window.innerHeight])
})
}
}, []);
return dimension;
};
// Navbar.components.ts
export const sidebar = {
open: (height) => ({
clipPath: `circle(${height + 200}px at 40px 40px)`,
transition: {
type: "spring",
stiffness: 20,
restDelta: 2
}
}),
closed: (height) => ({
clipPath: `circle(30px at ${300 - 60}px ${height - 65}px)`,
transition: {
delay: 0.2,
type: "spring",
stiffness: 400,
damping: 40
}
})
}
The issue remains where resizing the window does not affect the clipPath position of the circle. To illustrate the issue visually, the hamburger is supposed to be inside the green circle:
You can make a custom hook to listen to window resize.
You can modify solution from this link as per you requirement Custom hook for window resize
By using useState instead of ref, updating it on resize and returning the values to your main component
Here's an example:
export default function useWindowResize() {
const [dimension, setDimension] = useState([0, 0]);
useEffect(() => {
window.addEventListener("resize", () => {
setDimension([window.innerWidth, window.innerHeight])
});
return () => {
window.removeEventListener("resize", () => {
setDimension([window.innerWidth, window.innerHeight])
})
}
}, []);
return dimension;
}
and inside your main component use it like this:
const MainComponent = () => {
const [width, height] = useWindowResize();
useEffect(()=>{
// your operations
}, [width, height])
}
Your component will update every time the dimensions are changed. And you will get the updated width and height
EDIT:
Framer-motion provides a way to dynamically set variant's properties(for detailed guide refer to this Dynamically Update Variant) :-
// Navbar.tsx:
const Navbar: React.FC<NavbarProps> = () => {
return (
...
<MobileNavWrapper
initial={false}
custom={window.innerWidth} // custom={window.innerHeight} if variable depends on Height
animate={menuIsOpen ? "open" : "closed"}
custom={height}
ref={ref}
menuIsOpen={menuIsOpen}
>
<MobileNavBackground variants={sidebar} />
<MobileNav menuIsOpen={menuIsOpen} toggleMenu={toggleMenu} />
<MenuToggle toggle={() => toggleMenu()} />
</MobileNavWrapper>
)
}
// Navbar.components.ts
export const sidebar = {
open: (width) => ({
clipPath: `circle(${width+ 200}px at 40px 40px)`,
transition: {
type: "spring",
stiffness: 20,
restDelta: 2
}
}),
closed: (width) => ({
clipPath: `circle(30px at ${300 - 60}px ${width- 65}px)`,
transition: {
delay: 0.2,
type: "spring",
stiffness: 400,
damping: 40
}
})
}
Thanks to this thread: Framer Motion - stale custom value - changing the custom value doesn't trigger an update I found that the issue I'm having is a bug in framer-motion. To resolve this issue, add a key value to the motion component that's having issues re-rendering. This makes sure React re-renders the component.
In my case all I had to do was this:
<MobileNavBackground variants={sidebar} custom={windowHeight} key={key} />

GSAP Tween taking longer on each play in React

I am using GSAP's timeline to animate elements and it looks like it's taking longer and longer each time. In the example below, you can click on the box to animate it, and then click to reverse it. You can see in my setup that I don't have any delays set. If you open the console you will see the log takes longer and longer to execute the message in the onComplete function.
From research I've done, it looks like I am somehow adding a Tween, but I can't figure out how to solve this. Any help would be greatly appreciated. CodePen here.
const { useRef, useEffect, useState } = React
// set up timeline
const animTimeline = gsap.timeline({
paused: true,
duration: .5,
onComplete: function() {
console.log('complete');
}
})
const Box = ({ someState, onClick }) => {
const animRef = useRef();
animTimeline.to(animRef.current, {
x: 200,
})
useEffect(() => {
someState ? animTimeline.play() : animTimeline.reverse();
}, [someState])
return (
<div
className="box"
onClick={onClick}
ref={animRef}
>
</div>
)
}
const App = () => {
const [someState, setSomeState] = useState(false);
return(
<Box
someState={someState}
onClick={() => setSomeState(prevSomeState => !prevSomeState)}
/>
);
}
ReactDOM.render(<App />,
document.getElementById("root"))
Issue
I think the issue here is that you've the animTimeline.to() in the component function body so this adds a new tweening to the animation each time the component is rendered.
Timeline .to()
Adds a gsap.to() tween to the end of the timeline (or elsewhere using
the position parameter)
const Box = ({ someState, onClick }) => {
const animRef = useRef();
animTimeline.to(animRef.current, { // <-- adds a new tween each render
x: 200,
})
useEffect(() => {
someState ? animTimeline.play() : animTimeline.reverse();
}, [someState])
return (
<div
className="box"
onClick={onClick}
ref={animRef}
>
</div>
)
}
Solution
Use a mounting effect to add just the single tweening.
const animTimeline = gsap.timeline({
paused: true,
duration: .5,
onComplete: function() {
animTimeline.pause();
console.log('complete');
},
onReverseComplete: function() {
console.log('reverse complete');
}
})
const Box = ( { someState, onClick }) => {
const animRef = useRef();
useEffect(() => {
animTimeline.to(animRef.current, { // <-- add only one
x: 200,
});
}, []);
useEffect(() => {
someState ? animTimeline.play() : animTimeline.reverse();
}, [someState])
return (
<div
className="box"
onClick={onClick}
ref={animRef}
/>
)
};
Demo

how to add a classList in react using material UI

I'm trying to add a class to an element using Material UI in a scroll event like this.
const useStyles = makeStyles({
sticky: {
position: 'fixed',
top: 0,
width: '100%',
}
});
export default function myBar() {
React.useEffect(() => {
const myBar = document.getElementById("myBar");
const sticky = myBar.offsetTop;
const scrolling = window.addEventListener("scroll", () => {
if (window.pageYOffset > sticky) {
myBar.classList.add("sticky");
} else {
myBar.classList.remove("sticky");
}
});
return () => {
window.removeEventListener("scroll", scrolling);
};
}, []);
const classes = useStyles();
return (
<header id="myBar">
// some content
</header>
);
};
The problem is that Mterial Ui will generate some random numbers after class name, like sticky_123 so it will never be only sticky
Is it any way I can solve this problem?
The problem is that Material Ui will generate some random numbers after class name, like sticky_123 so it will never be only sticky
In order to use the className generated by Material UI, you must use classes.sticky instead of "sticky".
By the way, the component name should be in PascalCase.
const useStyles = makeStyles({
sticky: {
position: 'fixed',
top: 0,
width: '100%',
}
});
export default function MyBar() {
const classes = useStyles();
useEffect(() => {
const myBar = document.getElementById("myBar");
const sticky = myBar.offsetTop;
const scrollHandler = () => {
if (window.pageYOffset > sticky) {
myBar.classList.add(classes.sticky);
} else {
myBar.classList.remove(classes.sticky);
}
};
window.addEventListener("scroll", scrollHandler)
return () => {
window.removeEventListener("scroll", scrollHandler);
};
}, [classes]);
return (
<header id="myBar">
// some content
</header>
);
};

Resources