DEV Community

Cover image for Day 21: Animating transitions for a React app without external libraries
Masa Kudamatsu
Masa Kudamatsu

Posted on • Originally published at Medium

Day 21: Animating transitions for a React app without external libraries

TL;DR

With four steps of coding, we can animate transitions for a React app without relying on external libraries.

First, use four state values for UI change: closed, opening, open, and closing.

Second, render a component when the state value is either opening, open, or closing. In other words:

{state !== "closed" ? (
  <div />
) : null}
Enter fullscreen mode Exit fullscreen mode

Third, trigger the exiting animation by adding a data attribute to the exiting component:

{state !== "closed" ? (
  <div data-closing={state === "closing"} />
) : null}
Enter fullscreen mode Exit fullscreen mode

With the following CSS selectors, entering and exiting animation start when the state turns opening and closing, respectively:

div {
  animation: /* Set entering animation parameters here */
}
div[data-closing="true"] {
  animation: /* Set exiting animation parameters here */
}
Enter fullscreen mode Exit fullscreen mode

Finally, to switch the UI state from opening to open, or from closing to closed, use the onAnimationEnd prop:

{state !== "closed" ? (
  <div 
    data-closing={state === "closing"}
    onAnimationEnd={handleAnimationEnd} // ADDED
  />
) : null}
Enter fullscreen mode Exit fullscreen mode
const handleAnimationEnd = () => {
  if (state === 'opening') {
    setState('open');
  }
  if (state === 'closing') {
    setState('closed');
  }
}
Enter fullscreen mode Exit fullscreen mode

In the text below, I use an example of opening and closing a search box popup for more detailed description. Here is the CodeSandbox demo for this article.

1. Motivation

Implementing transition animation with React is a bit tricky. Entering animation is fine. We can simply use CSS animation, which will be triggered when a React component gets mounted. Difficulty comes with exiting animation. When a component gets dismounted, we have no opportunity to apply a set of CSS declarations for animation to that component.

There are a bunch of animation libraries to overcome this difficulty. The most famous is probably React Transition Group. There are also Framer Motion, React Spring, and Transition Hook.

To my eyes, all these libraries make the code for React components less intuitive than necessary. I'd rather code from scratch on my own to keep the code base easy to maintain.

Below I describe how I did so for My Ideal Map, a web app I’m building. As a specific example, this article aims to animate the opening and closing of a search box popup. But the same logic can be applied to other cases of transition animation.

For the ease of exposition, I stick to a simple transition animation of fade-in and fade-out (which I use for the reduced motion mode) so that the reader will not get distracted by the complexity of animation per se.

When the user presses a search button at the top-right corner of the screen, a popup with a search box fades in to the view. Then, when the user presses the close button in the popup, the popup fades out while the search button fade in to the view. Fade-in and fade-out transitions that this article will implement with React

2. UI state management with React

Imagine that an app does not show a search box by default, to save the screen space. Instead, it shows a search button the pressing of which will open the search box.

2.1 Initial state

I set the UI state as an object called ui with two properties, searchButton and searchBox. The initial values of these two properties are open and closed, respectively.

const [ui, setUi] = useState({
  searchButton: 'open',
  searchBox: 'closed',
});
Enter fullscreen mode Exit fullscreen mode

2.2 Opening search box

When the user presses the search button, the following event handler will be executed to change the UI state to closing and opening:

const handleClickSearchButton = () => {
  setUi({
    searchButton: 'closing',
    searchBox: 'opening',
  });
};
Enter fullscreen mode Exit fullscreen mode

This handler is going to be attached to the search button element as the onClick prop value (see below for detail).

Then, when the transition animation ends, which triggers the animationend event, the following event handler will be executed to set the UI state to be closed and open:

const handleAnimationEnd = () => {
  setUi({
    searchButton: 'closed',
    searchBox: 'open',
  });
Enter fullscreen mode Exit fullscreen mode

This handler is going to be attached to the form element, the parent of both the search button and the search box popup, as the onAnimationEnd prop value (see Section 2.6 below).

2.3 Closing search box

When the close button in the search box popup is pressed, the following event handler will set the UI state to be opening and closing:

const handleClickCloseButton = () => {
  setUi({
    searchButton: 'opening',
    searchBox: 'closing',
  });
};
Enter fullscreen mode Exit fullscreen mode

This handler is going to be attached to the close button element as the onClick prop value (see Section 2.5 below).

When the transition animation ends, the event handler will set the UI state to be the initial values. This is done by revising the handleAnimationEnd function as follows:

  const handleAnimationEnd = () => {
    if (ui.searchButton === 'closing') { // ADDED
      setUi({
        searchButton: 'closed',
        searchBox: 'open',
      });
    } // ADDED
    // ADDED FROM HERE
    if (ui.searchBox === 'closing') {
      setUi({
        searchButton: 'open',
        searchBox: 'closed',
      });
    }
    // ADDED UNTIL HERE
  };
Enter fullscreen mode Exit fullscreen mode

Since the animationend event is fired both when the search button completely disappears and when the search box popup completely disappears, I need to check which of these two events occurs. This is why I use the following two conditions:

if (ui.searchButton === 'closing') {}
Enter fullscreen mode Exit fullscreen mode
if (ui.searchBox === 'closing') {}
Enter fullscreen mode Exit fullscreen mode

2.4 Rendering search button

Now, we want to render the search button unless its UI state is closed:

{ui.searchButton !== 'closed' ? (
  <ButtonCloud
    aria-expanded="false"
    aria-label="Search a place on the map"
    onClick={handleClickSearchButton}
    type="button"
  >
    <!-- SVG code for magnifying glass icon (omitted) -->
  </ButtonCloud>
) : null}
Enter fullscreen mode Exit fullscreen mode

where the ButtonCloud component refers to the search button element. Its CSS code for animation will be set with Styled Components in Section 3 below.

Note that the handleClickSearchButton function, defined in Section 2.2 above, is attached as the onClick prop value.

2.5 Rendering search box

We also want to render the search box unless its UI state is closed:

{ui.searchBox !== 'closed' ? (
  <DivSearchBackground
    data-testid="div-search-background"
  >
    <CloseButton
      ariaLabel="Close search box"
      handleClick={handleClickCloseButton}
    />
    <SearchBox closeSearchBox={closeSearchBox} id="searchbox" />
  </DivSearchBackground>
) : null}
Enter fullscreen mode Exit fullscreen mode

where DivSearchBackground, CloseButton, and SearchBox refer to the popup, the close button, and the search box, respectively. Among these, DivSearchBackground will be styled with Styled Components for animation in Section 3 below.

Note that the handleClickCloseButton function, defined in Section 2.3 above, is attached to CloseButton as its handleClick prop (see Day 18 of this blog series how the CloseButton component is composed).

2.6 “Animaitonend” event handler

To listen to the animationend event, its event handler is attached to the <form role="search"> element that contains both the search button and the search box popup as its child elements. The entire JSX is now as follows:

<form 
  onAnimationEnd={handleAnimationEnd}
  role="search" 
>
  {ui.searchButton !== 'closed' ? (
    <ButtonCloud
      aria-expanded="false"
      aria-label="Search a place on the map"
      onClick={handleClickSearchButton}
      type="button"
    >
      <!-- SVG code for magnifying glass icon (omitted) -->
    </ButtonCloud>
  ) : null}
  {ui.searchBox !== 'closed' ? (
    <DivSearchBackground
      data-testid="div-search-background"
    >
      <CloseButton
        ariaLabel="Close search box"
        handleClick={handleClickCloseButton}
      />
      <SearchBox closeSearchBox={closeSearchBox} id="searchbox" />
    </DivSearchBackground>
  ) : null}
</form>
Enter fullscreen mode Exit fullscreen mode

As the event bubbles up in the DOM tree, both the end of animation for the search button (ButtonCloud) and for the search box popup (DivSearchBackground) will be caught by their parent element, the <form role="search"> element.

2.7 For styling the exit animation

Finally, to animate the disappearance of the search button and the search box popup, the data-closing attribute is added to them to trigger CSS animation:

return (
  <form role="search" onAnimationEnd={handleAnimationEnd}>
    {ui.searchButton !== 'closed' ? (
      <ButtonCloud
        aria-expanded="false"
        aria-label="Search a place on the map"
        data-closing={ui.searchButton === 'closing'} // ADDED
        onClick={handleClickSearchButton}
        type="button"
      >
        <!-- SVG code for magnifying glass icon (omitted) -->
      </ButtonCloud>
    ) : null}
    {ui.searchBox !== 'closed' ? (
      <FocusLock>
        <DivSearchBackground
          data-closing={ui.searchBox === 'closing'} // ADDED
          data-testid="div-search-background"
        >
          <CloseButton
            ariaLabel="Close search box"
            handleClick={handleClickCloseButton}
          />
          <SearchBox closeSearchBox={closeSearchBox} id="searchbox" />
        </DivSearchBackground>
      </FocusLock>
    ) : null}
  </form>
)
Enter fullscreen mode Exit fullscreen mode

This way, we can use the attribute selector

[data-closing="true"] {
  animation: ...
}
Enter fullscreen mode Exit fullscreen mode

to style the exit animation (see Section 3 below for more detail).

We’re done with the coding for React components. Now it’s time to style transition animation with CSS.

3. Setting animation parameters

3.1 Opening search box

For the search box to enter, we want it to fade in with the duration of 300ms and the linear easing.

The choice of 300ms is inspired from Material Design. Jonas Naimark, a Material Design team member, states as follows:

Since nav transitions usually occupy most of the screen, a long duration of 300ms is a good rule of thumb. — Naimark (2018).

The linear easing is typically used for animation that doesn’t involve any movement:

Linear motion can, for example, be used only when the object changes its color or transparency. Generally speaking, we can use it for the states when an object does not change its position. — Skytskyi (2018)

The fade-in animation can be achieved with the following keyframes and the backwards value of animation-fill-mode property:

@keyframes fade-in {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}
Enter fullscreen mode Exit fullscreen mode
animation-fill-mode: backwards;
animation-name: fade-in;
Enter fullscreen mode Exit fullscreen mode

The backwards value of animation-fill-mode prevents the flash of the default style before animation starts. Every HTML element has the opacity of 1 by default. If animation starts with the opacity value less than 1, we need animation-fill-mode: backwards.

At the same time as the search box enters, we want the search button to disappear with fade-out animation. To match the timing of animation, I use the same duration and easing values.

The fade-out animation can be defined as follows:

@keyframes fade-out {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode
animation-fill-mode: forwards;
animation-name: fade-in;
Enter fullscreen mode Exit fullscreen mode

By default, an HTML element’s default style (including opacity: 1) will appear after animation is over. To avoid the flash of an element after fade-out animation, therefore, we need the forwards value of aniamtion-fill-mode.

To summarize the animation parameters I have set so far, let’s define a JavaScript object called animation:

// ./utils/animation.js
import { keyframes } from "styled-components";
export const animation = {
  openSearchBox: {
    duration: "300ms",
    easing: "linear",
    button: {
      opacity: keyframes`
        from {
          opacity: 0;
        }
        to {
          opacity: 1;
        }
     `,
      fillMode: "backwards",
    },
    popup: {
      opacity: keyframes`
        from {
          opacity: 0;
        }
        to {
          opacity: 1;
        }
     `,
      fillMode: "backwards",
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Styled Components allow us to store keyframes as a variable with its keyframes helper (see Styled Components docs for detail).

To apply these parameters to the DivSearchBackground styled component for the search box popup, we code as follows:

// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';

...

const animateTransitionIn = css`
  animation-duration: ${animation.openSearchBox.duration};
  animation-fill-mode: ${animation.openSearchBox.popup.fillMode};
  animation-name: ${animation.openSearchBox.popup.opacity};
  animation-timing-function: ${animation.openSearchBox.easing};
`;

export const DivSearchBackground = styled.div`
  ...
  ${animateTransitionIn}
`;
Enter fullscreen mode Exit fullscreen mode

where the css helper is necessary to refer to the keyframes for the animation-name property.

To apply the animation parameters to the ButtonCloud styled component for the search button, we code as follows:

// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';

...

const animateTransitionOut = css`
  &[data-closing="true"] {
    animation-duration: ${animation.openSearchBox.duration};
    animation-fill-mode: ${animation.openSearchBox.button.fillMode};
    animation-name: ${animation.openSearchBox.button.opacity};
    animation-timing-function: ${animation.openSearchBox.easing};
  }
`;
export const ButtonCloud = styled.button`
  ...
  ${animateTransitionOut}
`;
Enter fullscreen mode Exit fullscreen mode

Note that I use the [data-closing="true"] attribute selector to define animation. This will apply only when the React state value for ui.searchButton is "closing" (see Section 2.7 above).

I believe this way of setting the parameters of transition animation is easier to maintain than using CSS variables with which (1) the nested structure of variables is infeasible and (2) the content of @keyframes cannot be stored. (Let me know if you have a different opinion.)

With the help of Styled Components (or other CSS-in-JS solutions), just going through one set of code, we can be reminded of how animation parameters were set by someone else or by ourselves in the past.

3.2 Closing search box

To close the search box, I want the fade-out animation with the duration of 250ms and the linear easing.

Duration is 50ms shorter than for opening the search box. According to the Material Design guidelines:

Transitions that close, dismiss, or collapse an element use shorter durations. Exit transitions may be faster because they require less attention than the user’s next task. — Google (undated)

For the search button to appear, I want the fade-in animation with the same duration and easing.

So we update the animation parameter object as follows:

// ./utils/animation.js
import { keyframes } from "styled-components";
export const animation = {
  openSearchBox: {
    ...
  },
  // ADDED FROM HERE
  closeSearchBox: {
    duration: "250ms",
    easing: "linear",
    button: {
      opacity: keyframes`
        from {
          opacity: 0;
        }
        to {
          opacity: 1;
        }
     `,
      fillMode: "backwards"
    },
    popup: {
      opacity: keyframes`
        from {
          opacity: 1;
        }
        to {
          opacity: 0;
        }
      `,
      fillMode: "forwards"
    },
  },
  // ADDED UNTIL HERE
};
Enter fullscreen mode Exit fullscreen mode

These parameters are applied to the two styled components as follows:

// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';

...

// ADDED FROM HERE
const animateTransitionOut = css`
  &[data-closing="true"] {
    animation-duration: ${animation.closeSearchBox.duration};
    animation-fill-mode: ${animation.closeSearchBox.popup.fillMode};
    animation-name: ${animation.closeSearchBox.popup.opacity};
    animation-timing-function: ${animation.closeSearchBox.easing};
  }
`;
// ADDED UNTIL HERE

export const DivSearchBackground = styled.div`
  ...
  ${animateTransitionIn}
  ${animateTransitionOut} /* ADDED */
`;
Enter fullscreen mode Exit fullscreen mode
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';

...

// ADDED FROM HERE
const animateTransitionIn = css`
  animation-duration: ${animation.closeSearchBox.duration};
  animation-fill-mode: ${animation.closeSearchBox.button.fillMode};
  animation-name: ${animation.closeSearchBox.button.opacity};
  animation-timing-function: ${animation.closeSearchBox.easing};
`;
// ADDED UNTIL HERE

export const ButtonCloud = styled.button`
  ...
  ${animateTransitionIn} /* ADDED*/
  ${animateTransitionOut}
 ;
Enter fullscreen mode Exit fullscreen mode

3.3 More elaborate transition animation

For My Ideal Map, the web app I’m building, I use more elaborate transition animation for the search box popup. That’ll be the topic of next two posts of this blog series.

However, the above simple fade-in and fade-out animation will be used for the reduced motion mode.

4. Demo

The entire code is available in the CodeSandbox for this blog post.

Compare this demo to the one without transition animation. I believe it feels nice and tender with transition animation.

References

Google (undated) “Speed”, Material Design, undated.

Naimark, Jonas (2018) “Motion Design Doesn’t Have to be Hard”, Google Design, Sep 27, 2018.

Skytskyi, Taras (2018) “The ultimate guide to proper use of animation in UX”, UX Collective, Sep 5, 2018.

Top comments (0)