Framer motion - animation doesn't start after state change using controls - reactjs

I made custom dots for an image slider which in general work just fine. The dots trigger the image slider animation but I now have to pause the dots when the slides are hovered. My problem is that the first dot animates on first load then it does nothing until I hover over a slide it will continue and then stop again.
Without the controls they work perfect.
I'm not sure where the problem is in my component.
Here is a code sandbox that demonstrates the problem.
export const TimerDots = ({
pauseDots,
amount,
duration,
handleChange,
activeIndex
}: {
pauseDots?: boolean;
amount: number;
duration: number;
handleChange: (e: any) => void;
activeIndex: number;
}) => {
const controls = useAnimation();
const handleNextSlide = () => {
handleChange((activeIndex += 1));
};
const startAnimation = controls.start({
width: SIZE * 3,
transition: {
duration: duration,
type: "tween",
ease: "easeOut"
}
});
useEffect(() => {
startAnimation;
if (pauseDots) {
controls.stop();
}
}, [pauseDots, activeIndex]);
return (
<DotContainer amount={amount}>
{[...Array.from({ length: amount })].map((_, index) => {
return (
<Dot
layout
key={index}
onClick={() => handleChange(index)}
$active={activeIndex === index}
>
{activeIndex === index && (
<Timer
layout
animate={controls}
onAnimationComplete={handleNextSlide}
/>
)}
</Dot>
);
})}
</DotContainer>
);
};

The problem comes from startAnimation and how its used in useEffect
When you do startAnimation in useEffect, you are not calling the function controls.start(..).
Try this:
const startAnimation = () => {
controls.start({
width: SIZE * 3,
transition: {
duration: duration,
type: "tween",
ease: "easeOut"
}
});
};
useEffect(() => {
startAnimation();
if (pauseDots) {
controls.stop();
}
}, [pauseDots, activeIndex, controls]);
CodeSandbox.

Related

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

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 test mousemove drag and drop with react-testing-library and framer-motion

I am trying to test the drag and drop functionality using react-testing-libary. The drag and drop functionality comes from framer-motion and the code is in reacy. From what I understand it uses the mousedown, mousemove and mouseup events to do this. I want to test drag and drop functionality of the following basic component:
export const Draggable: FC<DraggableInterface> = ({
isDragging,
setIsDragging,
width,
height,
x,
y,
radius,
children,
}) => {
return (
<motion.div
{...{ isDragging }}
{...{ setIsDragging }}
drag
dragConstraints={{
left: Number(`${0 - x}`),
right: Number(
`${width - x}`,
),
top: Number(`${0 - y}`),
bottom: Number(
`${height - y}`,
),
}}
dragElastic={0}
dragMomentum={false}
data-test-id='dragabble-element'
>
{children}
</motion.div>
);
};
And I have a snippet of the test as follows:
it('should drag the node to the new position', async () => {
const DraggableItem = () => {
const [isDragging, setIsDragging] = useState<boolean>(true);
return (
<Draggable
isDragging={isDragging}
setIsDragging={() => setIsDragging}
x={0}
y={0}
onUpdateNodePosition={() => undefined}
width={500}
height={200}
>
<div
style={{
height: '32px',
width: '32px'
}}
/>
</Draggable>
);
};
const { rerender, getByTestId } = render(<DraggableItem />);
rerender(<DraggableItem />);
const draggableElement = getByTestId('dragabble-element');
const { getByTestId, container } = render(
<DraggableItem />
);
fireEvent.mouseDown(draggableElement);
fireEvent.mouseMove(container, {
clientX: 16,
clientY: 16,
})
fireEvent.mouseUp(draggableElement)
await waitFor(() =>
expect(draggableElement).toHaveStyle(
'transform: translateX(16px) translateY(16px) translateZ(0)',
),
);
However, I cannot get the test to pass successfully as the transform value I test for is set to none. It does not update it the value with the updated CSS. I think there is some sort of async issue or animation delay so the mousemove is not detected and the value of the transform does not change. Would anyone know how to get the test to work or a way to test the mousemove changes?
Any advice or guidance on how I can solve this would be greatly appreciated!
It looks like you are invoking mouseMove() on the container instead of your draggable item. The container here refers to a root div containing your DraggableItem but is not the item itself (API). Therefore, events are being fired on the root div and not the item.
Here is a simple working example for testing draggable elements (for passers-by looking to test mouse down, move, and up events on draggable elements):
//
// file: draggable-widget.tsx
//
import $ from 'jquery'
import * as React from 'react'
type WidgetProps = { children?: React.ReactNode }
export default class DraggableWidget extends React.Component<WidgetProps> {
private element: React.RefObject<HTMLDivElement>
constructor(props: WidgetProps) {
super(props)
this.element = React.createRef()
}
show() { if (this.element.current) $(this.element.current).show() }
hide() { if (this.element.current) $(this.element.current).hide() }
getLocation() {
if (!this.element.current) return { x: 0, y: 0 }
return {
x: parseInt(this.element.current.style.left),
y: parseInt(this.element.current.style.top)
}
}
private onDraggingMouse(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
let location = this.getLocation()
let offsetX = e.clientX - location.x
let offsetY = e.clientY - location.y
let mouseMoveHandler = (e: MouseEvent) => {
if (!this.element.current) return
this.element.current.style.left = `${e.clientX - offsetX}px`
this.element.current.style.top = `${e.clientY - offsetY}px`
}
let reset = () => {
window.removeEventListener('mousemove', mouseMoveHandler)
window.removeEventListener('mouseup', reset)
}
window.addEventListener('mousemove', mouseMoveHandler)
window.addEventListener('mouseup', reset)
}
render() {
return (
<div ref={this.element} className="draggable-widget">
<div className="widget-header"
onMouseDown={e => this.onDraggingMouse(e)}>
<button className="widget-close" onClick={() => this.hide()}
onMouseDown={e => e.stopPropagation()}></button>
</div>
</div>
)
}
}
Then for the test logic:
//
// file: draggable-widget.spec.tsx
//
import 'mocha'
import $ from 'jquery'
import * as React from 'react'
import { assert, expect } from 'chai'
import { render, fireEvent } from '#testing-library/react'
import Widget from './draggable-widget'
describe('draggable widget', () => {
it('should move the widget by mouse delta-xy', () => {
const mouse = [
{ clientX: 10, clientY: 20 },
{ clientX: 15, clientY: 30 }
]
let ref = React.createRef<Widget>()
let { container } = render(<Widget ref={ref} />)
let element = $(container).find('.widget-header')
assert(ref.current)
expect(ref.current.getLocation()).to.deep.equal({ x: 0, y: 0 })
fireEvent.mouseDown(element[0], mouse[0])
fireEvent.mouseMove(element[0], mouse[1])
fireEvent.mouseUp(element[0])
expect(ref.current.getLocation()).to.deep.equal({
x: mouse[1].clientX - mouse[0].clientX,
y: mouse[1].clientY - mouse[0].clientY
})
})
})
Found this section in the react-testing-library docs
https://testing-library.com/docs/dom-testing-library/api-events/#fireeventeventname
Scroll down to the dataTransfer property section - apparently this is what we should be using to test drag-and-drop interactions

ReactJS hooks - drag and drop with multiple useState hooks and styled-components

I am fairly new to hooks and I am trying to implement a drag and drop container component that handles onDragStart, onDrag and onDragEnd functions throughout the mouse movement. I have been trying to replicate the code found here using hooks : https://medium.com/#crazypixel/mastering-drag-drop-with-reactjs-part-01-39bed3d40a03
I have almost got it working using the code below. It is animated using styled components. The issue is it works only if you move the mouse slowly. If you move the mouse quickly the SVG or whatever is contained in this div is thrown of the screen.
I have a component.js file that looks like
import React, { useState, useEffect, useCallback } from 'react';
import { Container } from './style'
const Draggable = ({children, onDragStart, onDrag, onDragEnd, xPixels, yPixels, radius}) => {
const [isDragging, setIsDragging] = useState(false);
const [original, setOriginal] = useState({
x: 0,
y: 0
});
const [translate, setTranslate] = useState({
x: xPixels,
y: yPixels
});
const [lastTranslate, setLastTranslate] = useState({
x: xPixels,
y: yPixels
});
useEffect(() =>{
setTranslate({
x: xPixels,
y: yPixels
});
setLastTranslate({
x: xPixels,
y: yPixels
})
}, [xPixels, yPixels]);
const handleMouseMove = useCallback(({ clientX, clientY }) => {
if (!isDragging) {
return;
}
setTranslate({
x: clientX - original.x + lastTranslate.x,
y: clientY - original.y + lastTranslate.y
});
}, [isDragging, original, lastTranslate, translate]);
const handleMouseUp = useCallback(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
setOriginal({
x:0,
y:0
});
setLastTranslate({
x: translate.x,
y: translate.y
});
setIsDragging(false);
if (onDragEnd) {
onDragEnd();
}
}, [isDragging, translate, lastTranslate]);
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp)
};
}, [handleMouseMove, handleMouseUp]);
const handleMouseDown = ({ clientX, clientY }) =>{
if (onDragStart) {
onDragStart();
}
setOriginal({
x: clientX,
y: clientY
});
setIsDragging(true);
};
return(
<Container
onMouseDown={handleMouseDown}
x={translate.x}
y={translate.y}
{...{radius}}
isDragging={isDragging}
>
{children}
</Container>
)
};
export default Draggable
And the styled components file, styled.js looks like the following:
import styled from 'styled-components/macro';
const Container = styled.div.attrs({
style: ({x,y, radius}) => ({
transform: `translate(${x - radius}px, ${y - radius}px)`
})
})`
//cursor: grab;
position: absolute;
${({isDragging}) =>
isDragging && `
opacity: 0.8
cursor: grabbing
`}
`;
export {
Container
}
So I pass in the initial value from the parent initially. I think i am not dealing with the useEffect / useState correctly and it is not getting the information fast enough.
I would be extremely grateful if someone can help me figure out how to fix this issue. Apologies again, but I am very new to using hooks.
Thanks You :)
Ideally, since setState is asynchronous you'd move all your state into one object (as the medium example does). Then, you can leverage the setState callback to make sure the values that each event listener and event callback is using are up-to-date when setState is called.
I think the example in that medium article had the same jumping issue (which is probably why the example video moved the objects slowly), but without a working example, it's hard to say. That said, to resolve the issue, I removed the originalX, originalY, lastTranslateX, lastTranslateY values as they're not needed since we're leveraging the setState callback.
Furthermore, I simplified the event listeners/callbacks to:
mousedown => mouse left click hold sets isDragging true
mousemove => mouse movement updates translateX and translateY via clientX and clientY updates
mouseup => mouse left click release sets isDragging to false.
This ensures that only one event listener is actually transforming x and y values.
If you want to leverage this example to include multiple circles, then you'll need to either reuse the component below OR use useRef and utilize the refs to move the circle that is selected; however, that's beyond the scope of your original question.
Lastly, I also fixed a styled-components deprecation issue by restructuring the styled.div.data.attr to be a function that returns a style property with CSS, instead of an object with a style property that is a function that returns CSS.
Deprecated:
styled.div.attrs({
style: ({ x, y, radius }) => ({
transform: `translate(${x - radius}px, ${y - radius}px)`
})
})`
Updated:
styled.div.attrs(({ x, y, radius }) => ({
style: {
transform: `translate(${x - radius}px, ${y - radius}px)`
}
}))`
Working example:
components/Circle
import styled from "styled-components";
const Circle = styled.div.attrs(({ x, y, radius }) => ({
style: {
transform: `translate(${x - radius}px, ${y - radius}px)`
}
}))`
cursor: grab;
position: absolute;
width: 25px;
height: 25px;
background-color: red;
border-radius: 50%;
${({ isDragging }) =>
isDragging &&
`
opacity: 0.8;
cursor: grabbing;
`}
`;
export default Circle;
components/Draggable
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import Circle from "../Circle";
const Draggable = ({ position, radius }) => {
const [state, setState] = useState({
isDragging: false,
translateX: position.x,
translateY: position.y
});
// mouse move
const handleMouseMove = useCallback(
({ clientX, clientY }) => {
if (state.isDragging) {
setState(prevState => ({
...prevState,
translateX: clientX,
translateY: clientY
}));
}
},
[state.isDragging]
);
// mouse left click release
const handleMouseUp = useCallback(() => {
if (state.isDragging) {
setState(prevState => ({
...prevState,
isDragging: false
}));
}
}, [state.isDragging]);
// mouse left click hold
const handleMouseDown = useCallback(() => {
setState(prevState => ({
...prevState,
isDragging: true
}));
}, []);
// adding/cleaning up mouse event listeners
useEffect(() => {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
return (
<Circle
isDragging={state.isDragging}
onMouseDown={handleMouseDown}
radius={radius}
x={state.translateX}
y={state.translateY}
/>
);
};
// prop type schema
Draggable.propTypes = {
position: PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number
}),
radius: PropTypes.number
};
// default props if none are supplied
Draggable.defaultProps = {
position: {
x: 20,
y: 20
},
radius: 10,
};
export default Draggable;

Resources