DEV Community

Cover image for Animating Content Between Components in React
wlytle
wlytle

Posted on

Animating Content Between Components in React

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>
            )
          }
        }
Enter fullscreen mode Exit fullscreen mode
/* 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;
}
Enter fullscreen mode Exit fullscreen mode

Alt Text
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,
        },
      },
    };
Enter fullscreen mode Exit fullscreen mode

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);
  };
Enter fullscreen mode Exit fullscreen mode

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];
  };
Enter fullscreen mode Exit fullscreen mode

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" : "";
Enter fullscreen mode Exit fullscreen mode

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)`,
        }}
        />
  );
};
Enter fullscreen mode Exit fullscreen mode
.force-move {
    transition: transform 1s;
    transition-delay: 1s;
    transform: translate(0, 0) !important;
  }
Enter fullscreen mode Exit fullscreen mode

Whew... We finally have some moving animations!!!!
Alt Text

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}
        />
...
Enter fullscreen mode Exit fullscreen mode
.force-moveX {
    transition: transform 1s;
    transform: translate(0) !important;
  }
  .force-moveY {
    transition: transform 1s;
    transition-delay: 1s;
    transform: translateY(0) !important;
  }
Enter fullscreen mode Exit fullscreen mode

and voila!
Alt Text

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
\* 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;
  }
Enter fullscreen mode Exit fullscreen mode

Discussion (0)