In this post we will go through creating a function based React component utilizing hooks to create a reusable component that simplifies transition animations. By the end of this post, we will have a basic component that will display a slide transition on the change of child objects like this:

Animation Example

We will be going through the basic app setup and iterative process of building this component; however, if you would like to skip to the finished result click here.

Basic App

To start, lets define a basic React app that displays a card and allows the user to cycle through the available displayed cards by clicking a next button.

This app example has two basic components, a Card that displays a title and text, and a Container that handles the display of individual cards and handles navigation to the next card based on a button click.

function CardDisplay(props){
  return <div className={"card"}>
    <span className={"title"}>{props.title}</span>
    <p>{props.text}</p>
  </div>;
}

function MainDisplay(props){
  const [cardPos, setCardPos] = useState(0);
  
  var currentCard = <CardDisplay title={props.cards[cardPos].title} text={props.cards[cardPos].text}/>;
  
  var nextButton = <button onClick={(event)=>{
          var nextPos = cardPos + 1;
          if(nextPos >= props.cards.length){
            nextPos = 0;
          }
          setCardPos(nextPos);
        }}>
        {"Next"}
      </button>;
  
  return <div>
    {currentCard}
    {nextButton}
  </div>;
}

Now that we have this basic app example, lets see what it looks like.

Goals for the Animation

This animation should slide the next card into place from the right hand side and slid the old card out to the left. Although this is a rather simple animation, we want as little change to the main components as possible. In order to do this, and to make the logic modular, this implementation will use a custom hook to handle the rendering and animation control. Now that we have a basic concept of the goal of this hook, lets get started.

Basic React Component Hook Design

For this to work properly, lets first create a basic react componet that will be our animated container. At it's most basic, this will return a div containing the child elements passed to it.

function AnimationComponent(props){
  return <div>{props.children}</div>;
}

When implemented, every time the button is pressed, the react component will send the next card to the AnimationContainer which will render our basic div with that card in it. Now that we have a basic container, we need the contents of that AnimationContainer to be animated on child change, this is where our custom hook comes in. Lets do a quick outline of what this hook will do and when an animation needs to take place.

  • The animation will trigger on a change to the children passed to the animated container
  • The animation process requires both the prior and new elements to be displayed
  • After the animation cycle is complete, we need to remove the prior element from our display entirely.

Now that we have the basic requirements, lets start writing and implementing the hook.

function useAnimationState(children){
  const [animatedItems, setItems] = useState(null);
  
  useEffect(()=>{
    var newState = animatedItems.concat([children]);
    setItems(newState);
  }, [children]);
  
  return animatedItems;
}

function AnimationComponent(props){
  var animatedItems = useAnimationState(props.children);
  
  return <div className={"animatedContainer"}>
    {animatedItems}
  </div>
}

This hook now listens for a change to the child elements and add the new item to an array of all past items. Our behavior now looks like this.

At this point, we are properly listening to the child change event and displaying more than 1 child element without any change to our core components. Now we need to trigger the animations and clean up after the animation is complete.

Lets start with styling and animations. Animating the transition for this case will require two basic classes and an animation keyframe definition. Lets start by making the container display the cards next to each other.

.animatedContainer {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
}

Next we need to add or class and keyframe animation for our prior/leaving element.

@keyframes animateSlideOut {
  0%   {
    margin-left: 0px;
  }
  100% {
    margin-left: -325px;
  }
}

.slideOut {
  animation: animateSlideOut 0.8s;
}

Now lets update our hook to include the following:

  • assigning our new css class for animating the prior element
  • add an animation end event to the prior element
  • once the prior element animation has completed, remove that item from the render
function useAnimationState(children){
  const [animatedItems, setItems] = useState(null);
  
  function animationEnd(){
    // update the animatedItems to remove the first item
    setAnimatedItems(itemRef.current.slice(1));
  }
  
  useEffect(()=>{
    if(animatedItems == null){
      // initial render of the component does not need a transition animation
      setAnimatedItems([children]);
    } else {
      // on updates to the children, updated the animated chilren array to contain the new element
      // the first element in the array should be wrapped with the slide out animation
      
      setAnimatedItems([<div className={"slideOut"} onAnimationEnd={animationEnd}>{animatedItems[0]}</div>].concat([children]));
    }
  }, [children])
  
  return animatedItems;
}

function AnimationComponent(props){
  var animatedItems = useAnimationState(props.children);
  
  return <div className={"animatedContainer"}>
    {animatedItems}
  </div>
}

With these basic updates we are starting to get a smooth animation that looks like this.

This looks much better, but we can still add some additional animations keyframe properties to the new element to make the entry animation a little smoother.

@keyframes animateFadeIn {
  0%   {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

.fadeIn {
  animation: animateFadeIn 0.8s;
}

Now that we have a fade in animation defined, lets update the hook to use it on new items coming in.

function useAnimationState(children){
  const [animatedItems, setItems] = useState(null);
  const itemRef = useRef(animatedItems)
  var refUpdateProxy = {
    apply: (target, thisArg, argumentsList) =>{
      itemRef.current = argumentsList[0];
      return Reflect.apply(target, thisArg, argumentsList);
    }
  }
  const setAnimatedItems = new Proxy(setItems, refUpdateProxy)
  
  function animationEnd(){
    // update the animatedItems to remove the first item
    setAnimatedItems(itemRef.current.slice(1));
  }
  
  useEffect(()=>{
    if(animatedItems == null){
      // initial render of the component does not need a transition animation
      setAnimatedItems([children]);
    } else {
      // on updates to the children, updated the animated chilren array to contain the new element
      // the first element in the array should be wrapped with the slide out animation
      
      setAnimatedItems([<div className={"slideOut"} onAnimationEnd={animationEnd}>{animatedItems[0]}</div>].concat([<div className={"fadeIn"}>{children}</div>]));
    }
  }, [children])
  
  return animatedItems;
}

Now that both elements are animated, lets take a look again at our transition.

We are now seeing some issues with the entry element cutting in and out some. The most likely cause of this issue has to due with the React element reconciliation. Basically on completion of the render cycle that removes the old element, react thinks that the new array to display has no direct association with the previously rendered frame, this causes unnecessary unmounting and  mounting of the DOM element instead of just updating the item. We can fix this by letting react now the elements relation using the React key property. To do this, lets start by adding a key property to each card in our main component.

function MainDisplay(props){
  const [cardPos, setCardPos] = useState(0);
  
  var currentCard = <CardDisplay title={props.cards[cardPos].title} text={props.cards[cardPos].text} key={cardPos}/>;
  
  var nextButton = <button onClick={(event)=>{
          var nextPos = cardPos + 1;
          if(nextPos >= props.cards.length){
            nextPos = 0;
          }
          setCardPos(nextPos);
        }}>
        {"Next"}
      </button>;
  
  return <div>
    <AnimationComponent>{currentCard}</AnimationComponent>
    {nextButton}
  </div>;
}

Next, we need to update the hook to make sure our component structure does not change between render cycles, and that we implement a key on each animated div based on this card key.

function useAnimationState(children){
  const [animatedItems, setItems] = useState(null);
  const itemRef = useRef(animatedItems)
  const setAnimatedItems = (newState)=>{
    itemRef.current = newState;
    return setItems(newState);
  }
  const priorItem = useRef();
  
  function animationEnd(){
    // update the animatedItems to remove the first item
    setAnimatedItems(itemRef.current.slice(1));
  }
  
  useEffect(()=>{
    var key = `${children.key}_animated`;
    
    if(animatedItems == null){
      // initial render of the component does not need a transition animation
      setAnimatedItems([<div key={key}>{children}</div>]);
      priorItem.current = children;
    } else {
      // on updates to the children, updated the animated chilren array to contain the new element
      // the first element in the array should be wrapped with the slide out animation
      var priorItemKey = `${priorItem.current.key}_animated`;
      setAnimatedItems([<div key={priorItemKey.current} className={"slideOut"} onAnimationEnd={animationEnd}>{priorItem.current}</div>].concat([<div key={key} className={"fadeIn"}>{children}</div>]));
    }
  }, [children])
  
  return animatedItems;
}

Completed Component

We now have a completed slide animation component that handles slide transitions between changes. You can see the example animation and completed code in the Codepen below on the Github repo.