wlytle
Posted on January 4, 2021
I've been working on a game app in React with some fellow students in Flatiron's Software Engineering Bootcamp and found myself in my least favorite place; staring down the barrel of a complex CSS challenge. I wanted to animate pieces around the game board after a player made a move. After a fair bit of googling React animation libraries (This is a great source for animation options), I decided that my use case combined with my desire to make peace with CSS meant I was going the full CSS route. This was no simple task for me so I thought I'd share what I learned.
TL;DR
The high-level view of my approach is to have resources animate into new components when the user clicks on them. To achieve this I have the resources render in the new component with a state change and force them back into their original position with CSS, then animate them into their new position. If you just want to get down to the code and see the results, you can check out the code pen or see the full code at the end of the article.
Set Up
I pared down the problem as best I could to make it bite-sized. Here is the basic boilerplate code of the pre-animated situation.
//App.js
const FarmFig = ({ image, handleClick=null, setRef }) => {
return (
<img className="image" src={image} ref={setRef} onClick={handleClick} />
);
};
class App extends React.Component {
constructor(){
super()
this.center = React.createRef();
this.state = {
this.state = {
images: {
tl:
"https://lh3.googleusercontent.com/proxy/YSIR4H4fU2Tf5vmbmeixy6m6ZcTXvS9wEo8q4gxOiqEg8XXPod1ZaGJbc8-wngYJwkR6QHEfjvO3w4QogZJqVH5nJjhJaMk",
c:
"https://lh3.googleusercontent.com/proxy/29-YDS42UPIZNuPicKnpkmh2sw_th3Sa41d6iiGT8XH1vXfjfpNgUCK1-CxlMlT40eaJP25ylJ8IRUiCEBwTfIyuBB8izJ8",
br:
"https://pngarchive.com/public/uploads/small/11559054782q4dsokodad1svijk1zzlyrjdwtmlygkkaxxvooeqevdyrbomu3b5vwcwst0ndcidr89gdf0nyleyffoncphgazeqmnpmdubfypow.png",
},
}
}
handleClick = ({target}) => {
this.setState(prevState => {
//switch clicked image with the center image
if (prevState.images.tl === target.src) {
prevState.images.tl = prevState.images.c;
prevState.images.c = target.src
} else {
prevState.images.br = prevState.images.c;
prevState.images.c = target.src
}
return {images: prevState.images}
})
}
render() {
const{tl, c, br} = this.state.images
return (
<div className="container">
<div className="top-left">
<FarmFig image={tl} handleClick={this.handleClick}/>
</div>
<div className="container">
<FarmFig image={c} setRef={this.center} />
</div>
<div className="bot-right">
<FarmFig image={br} handleClick={this.handleClick} />
</div>
</div>
)
}
}
/* css */
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
justify-content: center;
height: 90vh;
width: 100%;
}
.top-left {
align-self: flex-start;
transform: rotate(180deg)
}
.bot-right {
align-self: flex-end;
}
.image {
width: 175px;
}
So far, pretty basic set up. We have three images: top-left, center, and bottom-right. When we click on either the top-left or bottom-right images we trigger a state change that swaps the clicked and center images. We also included a ref
to the center image, we'll use that soon. Now to animate this transition.
Adding animation
In order to animate the images, we first need to add a few items to state.
this.state = {
images: {
tl:
"https://lh3.googleusercontent.com/proxy/YSIR4H4fU2Tf5vmbmeixy6m6ZcTXvS9wEo8q4gxOiqEg8XXPod1ZaGJbc8-wngYJwkR6QHEfjvO3w4QogZJqVH5nJjhJaMk",
c:
"https://lh3.googleusercontent.com/proxy/29-YDS42UPIZNuPicKnpkmh2sw_th3Sa41d6iiGT8XH1vXfjfpNgUCK1-CxlMlT40eaJP25ylJ8IRUiCEBwTfIyuBB8izJ8",
br:
"https://pngarchive.com/public/uploads/small/11559054782q4dsokodad1svijk1zzlyrjdwtmlygkkaxxvooeqevdyrbomu3b5vwcwst0ndcidr89gdf0nyleyffoncphgazeqmnpmdubfypow.png",
},
animate: true,
transition: {
center: {
startTop: 0,
startRight: 0,
},
corner: {
farmItem: null,
startTop: 0,
startRight: 0,
},
},
};
We've added a transition
object to keep track of the adjusted starting position of the images after they re-render. Remember, we are going to render the swapped images in their new components and move them to look like they are still in their original positions then animate them to their new location.
Next, we need to calculate these adjusted starting positions after we click on an image. This will be done in our handlClick
function.
handleClick = ({ target }) => {
// find location of clicked image
const imageStartTop =
target.getBoundingClientRect().top + document.documentElement.scrollTop;
const imageStartRight =
target.getBoundingClientRect().right +
document.documentElement.scrollLeft;
//find ending location of clicked image
let endLoc = this.getCenterLoc();
let selectedImage;
this.setState((prevState) => {
if (prevState.images.tl === target.src) {
// Swap the selected and center images
selectedImage = "tl";
prevState.images.tl = prevState.images.c;
prevState.images.c = target.src;
} else {
selectedImage = "br";
prevState.images.br = prevState.images.c;
prevState.images.c = target.src;
}
return {
images: prevState.images,
// We set animate to false to temporarily to allow images to relocate
animate: false,
transition: {
center: {
// y distance in px the center image needs to move
startTop: imageStartTop - endLoc[0],
// x distance in px the center image needs to move
startRight: imageStartRight - endLoc[1],
},
corner: {
farmItem: selectedImage,
// y distance in px the clicked image needs to move
startTop: endLoc[0] - imageStartTop,
// y distance in px the clicked image needs to move
startRight: endLoc[1] - imageStartRight,
},
},
};
});
// Wait briefly then change the animation flag to trigger a re-render and the animation.
setTimeout(() => this.setState({ animate: true }), 200);
};
where:
getCenterLoc = () => {
const imageEndTop =
this.center.current.getBoundingClientRect().top +
document.documentElement.scrollTop;
const imageEndRight =
this.center.current.getBoundingClientRect().right +
document.documentElement.scrollLeft;
return [imageEndTop, imageEndRight];
};
There's a lot going on here, but at the end of the day, it's just a cumbersome subtraction problem. We use the ref
on the center image and the event target of the click to get the position of clicked and center image relative to the viewport. element.getBoundingClientRect()
gives the coordinates of element
relative to the document and adding document.documentElement.scrollTop
accounts for any offset in the document if the page is scrolled. Then we set our state with the difference between the center image and the selected image.
After this, we use a setTimeout
call to wait briefly before changing the animate
flag in state and triggering the animation.
Now when we need to apply these new locations to the appropriate images when they are clicked. In our render method, we will add a ternary checking our this.state.animate
property
const animClass = this.state.animate ? "force-move" : "";
Then we can pass this new class, along with our adjusted locations down into the appropriate FarmFig
components, and make the following updates to FarmFig
and to our CSS file.
const FarmFig = ({ image, handleClick = null, setRef, locs, animClass }) => {
return (
<img
className={`image ${animClass}`}
src={image}
ref={setRef}
onClick={handleClick}
style={{
transform: `translate(${locs[1]}px, ${locs[0]}px)`,
}}
/>
);
};
.force-move {
transition: transform 1s;
transition-delay: 1s;
transform: translate(0, 0) !important;
}
Whew... We finally have some moving animations!!!!
This is great, except... I originally needed animations to move pieces around a space, not through it. Unfortunately, CSS doesn't support putting delays between multiple types of transforms
on the same object. My solution to this is to add some bonus divs
around the FarmFig
img
and transform each of those with timed delays (This definitely feels a bit hacky, anyone have a better idea?)
Finally updating our FarmFig
and CSS files accordingly:
<div
className={`${animClass}X`}
style={{
transform: `translate(${locs[1]}px`,
}}
>
<div
className={`${animClass}Y`}
style={{
transform: `translateY(${locs[0]}px)`,
}}
>
<img
className={`image`}
src={image}
ref={setRef}
onClick={handleClick}
/>
...
.force-moveX {
transition: transform 1s;
transform: translate(0) !important;
}
.force-moveY {
transition: transform 1s;
transition-delay: 1s;
transform: translateY(0) !important;
}
Wrap-up
Building out custom animations in React using just CSS is undeniably cumbersome and requires a fair bit of code. It is certainly not for every situation. However, if you want to limit how many libraries your project relies on, have a unique use case, or have decided it's finally your time to face CSS it can certainly be done. Hope this is helpful, let me know if I totally screwed something up!
Full Code
CodePen
Code
// App.js
const FarmFig = ({ image, handleClick = null, setRef, locs, animClass }) => {
return (
<div
className={`${animClass}X`}
style={{
transform: `translate(${locs[1]}px`,
}}
>
<div
className={`${animClass}Y`}
style={{
transform: `translateY(${locs[0]}px)`,
}}
>
<img
className={`image`}
src={image}
ref={setRef}
onClick={handleClick}
/>
</div>
</div>
);
};
class App extends React.Component {
constructor() {
super();
this.center = React.createRef();
this.state = {
images: {
tl:
"https://lh3.googleusercontent.com/proxy/YSIR4H4fU2Tf5vmbmeixy6m6ZcTXvS9wEo8q4gxOiqEg8XXPod1ZaGJbc8-wngYJwkR6QHEfjvO3w4QogZJqVH5nJjhJaMk",
c:
"https://lh3.googleusercontent.com/proxy/29-YDS42UPIZNuPicKnpkmh2sw_th3Sa41d6iiGT8XH1vXfjfpNgUCK1-CxlMlT40eaJP25ylJ8IRUiCEBwTfIyuBB8izJ8",
br:
"https://pngarchive.com/public/uploads/small/11559054782q4dsokodad1svijk1zzlyrjdwtmlygkkaxxvooeqevdyrbomu3b5vwcwst0ndcidr89gdf0nyleyffoncphgazeqmnpmdubfypow.png",
},
animate: true,
transition: {
center: {
startTop: 0,
startRight: 0,
},
corner: {
farmItem: null,
startTop: 0,
startRight: 0,
},
},
};
}
getCenterLoc = () => {
const imageEndTop =
this.center.current.getBoundingClientRect().top +
document.documentElement.scrollTop;
const imageEndRight =
this.center.current.getBoundingClientRect().right +
document.documentElement.scrollLeft;
return [imageEndTop, imageEndRight];
};
handleClick = ({ target }) => {
// find location of clicked image
const imageStartTop =
target.getBoundingClientRect().top + document.documentElement.scrollTop;
const imageStartRight =
target.getBoundingClientRect().right +
document.documentElement.scrollLeft;
//find location of ending location
let endLoc = this.getCenterLoc();
let selectedImage;
this.setState((prevState) => {
if (prevState.images.tl === target.src) {
selectedImage = "tl";
prevState.images.tl = prevState.images.c;
prevState.images.c = target.src;
} else {
selectedImage = "br";
prevState.images.br = prevState.images.c;
prevState.images.c = target.src;
}
return {
images: prevState.images,
animate: false,
transition: {
center: {
startTop: imageStartTop - endLoc[0],
startRight: imageStartRight - endLoc[1],
},
corner: {
farmItem: selectedImage,
startTop: endLoc[0] - imageStartTop,
startRight: endLoc[1] - imageStartRight,
},
},
};
});
setTimeout(() => this.triggerAnim(), 200);
};
triggerAnim = () => {
this.setState({ animate: true });
};
getOldLoc = (loc) => {
const { corner } = this.state.transition;
let top, right;
if (corner.farmItem === loc) {
top = corner.startTop;
right = corner.startRight;
} else {
top = 0;
right = 0;
}
return [top, right];
};
render() {
const { tl, c, br } = this.state.images;
const { center } = this.state.transition;
const animClass = this.state.animate ? "force-move" : "";
return (
<div className="container">
<div className="top-left">
<FarmFig
image={tl}
handleClick={this.handleClick}
locs={this.getOldLoc("tl")}
animClass={animClass}
/>
</div>
<div className="center">
<FarmFig
image={c}
setRef={this.center}
locs={[center.startTop, center.startRight]}
animClass={animClass}
/>
</div>
<div className="bot-right">
<FarmFig
image={br}
handleClick={this.handleClick}
locs={this.getOldLoc("br")}
animClass={animClass}
/>
</div>
</div>
);
}
}
\* css *\
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
height: 90vh;
width: 100%;
}
.center {
align-self: center;
}
.top-left {
align-self: flex-start;
}
.bot-right {
align-self: flex-end;
}
.image {
width: 150px;
height: 130px;
}
.force-moveX {
transition: transform 1s;
transform: translate(0) !important;
}
.force-moveY {
transition: transform 1s;
transition-delay: 1.5s;
transform: translateY(0) !important;
}
Posted on January 4, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.