DEV Community

Cover image for Day 23: Animating the closing of a popup as if button ripple effect wipes it away
Masa Kudamatsu
Masa Kudamatsu

Posted on • Originally published at Medium

Day 23: Animating the closing of a popup as if button ripple effect wipes it away

TL;DR

A white popup with mix-blend-mode: lighten and color: black can be wiped away with its child whose background-color is currentColor.

Suppose you have the following DOM structure for a popup:

<div class="popup" data-closing='false'>
  <span class="ripple"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

where the data-closing attribute will turn true when the user presses the close button of the popup.

Then, the following CSS code

.popup[data-closing='true'] {
  background-color: white;
  color: black;
  mix-blend-mode: lighten;
}
.popup[data-closing='true'] .ripple {
  background-color: currentColor;
  animation: 300ms linear backwards expand 
}
@keyframes expand {
  from {
    transform: scale(0)
  }
  to {
    transform: scale(1)
  }
}
Enter fullscreen mode Exit fullscreen mode

creates an impression that the popup is wiped away (as shown in the GIF below), because the span element is rendered with the background beneath the popup(!).

This article describes how I apply this technique to the popup for the web app I’m currently developing with React and Styled Components.

A popup is initially shown above Google Maps. After the user presses the close button at its top-right corner, a ripple erases the popup and reveals the map beneath from top right to bottom left.The animation this article will implement (a screen capture of the demo for this article)

1. Introduction

1.1 Problems

On Day 22 of this blog series, I animated the opening of a search box popup with the container transform pattern of Material Design. For closing the search box, then, Material Design guidelines would suggest the reversed version of the container transform pattern: the popup shrinks and morphs back into the button. Such animation reinforces the idea that closing the popup will not destory it but store it inside the container.

For My Ideal Map (the web app I’m building), however, a popup is likened to a cloud floating over the bird’s eye view of streets beneath (that is, the embedded Google Maps).
A street map overlaid with a blurry white rectangle featuring the cross mark at its top right
An embedded Google Maps overlaid with a cloud-like popup (a screenshot of the prototype version of My Ideal Map)

We never see clouds in the sky moving in one direction and then in the reversed direction. The reversed container transform animation would therefore break an illusion of the popup as a cloud.

I need another type of animation for closing the popup.

1.2 An inspirational UI design work

Searching for an inspiration, I stumbled upon a Dribbble work created by Rasmussen (2019). In his work, pressing the close button will erase a card from top-right to bottom-left:
A square card with the close button at the top-right corner gets erased from top right to bottom left after pressing the close button(A screen capture of animation by Rasmussen (2019))

I think it is a clever UI design: it combines the animation of removing the card with the indication of the close button being pressed.

I immediately thought this animation could be useful to reinfoce the idea of a popup as a cloud. Pressing the top-right corner of a cloud would create the movement of air in the sky, blowing the cloud away.

1.3 Implementation with CSS

To implement this animation with CSS and JavaScript, Tudor (2021) proposes a clever technique.

First, for the black card (assuming its class selector is .card), set its color and mix-blend-mode properties as follows:

.card {
  color: #fff;
  mix-blend-mode: darken;
}
Enter fullscreen mode Exit fullscreen mode

which turns text into “transparent”, revealing the background beneath the card. The darken value of mix-blend-mode picks the lowest RGB values among all the layers of HTML elements for each pixel. If the text color is #fff, that is, the brightest RGB values, the background color beneath the card will be picked as the lowest RGB values in all the pixels, with text turning“transparent” as a result.

Then, create a pseudo element of the card with its background property value equal to currentColor:

.class::after {
  background: currentColor;
  content: '';
  position: absolute;
    top: 0; right: 0; bottom: 0; left: 0;
}
Enter fullscreen mode Exit fullscreen mode

which overlays the black card with a copy of the card that is filled with the background beneath the black card. In other words, the black card appears to be erased away(!). By scaling up this pseudo element from zero to its full size with animation, therefore, we can create a visual effect that appears like erasing the black card.

Tudor (2021), however, animates the clip-path property for scaling up the pseudo element. While Chromium’s browser developer team seems to be working hard to make the clip-path animation more performant (see Kravets 2021), it is still safe to animate either transform or opacity property for the best performance (see Chikuyonok 2016, for example). So we can instead animate the transform property to scale up the pseudo element.

1.4 Adaptations

Now, the animation by Rasmussen (2019) uses a rectangle to erase the card. There is no rectangle in nature, certainly not in the sky. To erase the cloud-like popup, therefore, something circular will look more natural.

Then I thought, how about erasing the popup with the button ripple effect, the ripple created after the pressing of the close button?

That’s what I’m going to implement below, by combining the CSS technique of Tudor (2021) with the button ripple effect by Cameron (2020).

2. Ripple

2.1 Rendering as a React component

Instead of using a pseudo element as in Tudor (2021), I will use a span element as a React component to erase the popup. This is because I need to specify the size and position of the ripple each time the user presses the close button. By rendering the ripple as a React component, we can set its size and position as the component’s prop values.

First, turn the span element into a circle and allow it to be positioned freely inside the parent element, with Styled Components:

// ./styled-components/SpanRipple.js
import styled from 'styled-components';

const shapeRipple = `
  border-radius: 50%;
`;
const positionRipple = `
  position: absolute;
`;
export const SpanRipple = styled.span`
  ${shapeRipple}
  ${positionRipple}
`;
Enter fullscreen mode Exit fullscreen mode

Next, render this ripple as a React component. On Day 21 of this blog series, I defined the UI state as:

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

When the search box was closing, the ui.searchBox value would change to 'closing' while the ui.searchButton value would turn to 'opening'. This was done with the click event handler:

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

Consequently, I can render the ripple when the search box is closing, with the following JSX:

<DivSearchBackground
  data-closing={ui.searchBox === 'closing'}
>
  <CloseButton
    handleClick={handleClickCloseButton}
  />
  {ui.searchBox === 'closing' ? (
    <SpanRipple
      id="ripple"
    />
  ) : null}
</DivSearchBackground>
Enter fullscreen mode Exit fullscreen mode

where DivSearchBackground is the div element styled as the cloud-like popup on Day 19, and CloseButton is the button element with the cross (x) icon as its label (see Day 18 for detail).

2.2 Styling dynamically

I need to style the ripple dynamically. For one thing, the ripple needs to be as large as the popup whose size can vary when the desktop user changes the browser window size (otherwise, the ripple will not erase the entire popup). For another, the ripple needs to start at the location where the user presses the button.

We can do this by editing the click event handler for the close button as follows:

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

The idea is that the CloseButton component will obtain the three values of rippleDiameter (the diameter of the ripple), ripplePositionLeft and ripplePositionTop (the x and y coordinates of the top-left corner of the ripple element, respectively) in response to the user’s click of the button. The handleClickCloseButton function, passed to the CloseButton component as a prop, will then be executed with these three values as its arguments. Then, it will update the UI state with the three values.

Then I use the UI state values to style the ripple dynamically:

<DivSearchBackground
  data-closing={ui.searchBox === 'closing'}
>
  <CloseButton
    handleClick={handleClickCloseButton}
  />
  {ui.searchBox === 'closing' ? (
    <SpanRipple
      id="ripple"
      style={{                       // ADDED
        height: ui.rippleDiameter,   // ADDED
        left: ui.ripplePositionLeft, // ADDED
        top: ui.ripplePositionTop,   // ADDED
        width: ui.rippleDiameter,    // ADDED
      }}                             // ADDED
    />
  ) : null}
  // ADDED UNTIL HERE
</DivSearchBackground>
Enter fullscreen mode Exit fullscreen mode

When the UI state for the search box popup becomes closing, the SpanRipple styled component will be rendered with its height, left, top, and width CSS values supplied from the UI state values.

In the file for the CloseButton component, then, the handleClickCloseButton function is passed as the handleClick prop and will be executed inside the button click handler:

// ./components/CloseButton.js
export const CloseButton = ({handleClick}) => {
  const clickHandler = event => {

    // To be inserted here: the calculation of rippleDiameter, ripplePositionLeft, and ripplePositionTop

    handleClick({    
      rippleDiameter,
      ripplePositionLeft,
      ripplePositionTop
    });
  };
  return (
    <button
      onClick={clickHandler}
      type="button"
    >
      <!-- SVG code for cross mark (x) (omitted) -->
    </button>
  )
};
Enter fullscreen mode Exit fullscreen mode

2.3 Calculating geometry

Now, let’s get into the detail of calculating the size and position of the ripple.

Inside the handler, I first retrieve the popup element which is the parent of the close button:

const clickHandler = event => {
  const popup = event.currentTarget.offsetParent; 
}
Enter fullscreen mode Exit fullscreen mode

where event.currentTarget refers to the button element, the element to which the event handler is attached (if we use event.target instead, this will refer to the svg element that triggers the click event). And the offsetParent property of an HTML element] refers to the popup div element because its position property is set to be absolute (see Day 19 of this blog series.

I then retrieve the popup element’s geometry with the getBoundingClientRect() method:

const clickHandler = event => {
  const popup = event.currentTarget.offsetParent; 

  // ADDED FROM HERE
  const {
    left: popupLeft,
    top: popupTop,
    height: popupHeight,
    width: popupWidth,
  } = popup.getBoundingClientRect();
  // ADDED UNTIL HERE
}
Enter fullscreen mode Exit fullscreen mode

where I use the destructuring assignment to simplify the code.

I want the ripple’s radius to be as large as the popup’s diagonal so that the ripple in its full size completely covers the popup rectangle:

const clickHandler = event => {
  const popup = event.currentTarget.offsetParent; 
  const {
    left: popupLeft,
    top: popupTop,
    height: popupHeight,
    width: popupWidth,
  } = popup.getBoundingClientRect();

  // ADDED FROM HERE
  const popupDiagonalLength = Math.sqrt(
    Math.pow(popupWidth, 2) + Math.pow(popupHeight, 2),
  );
  const rippleRadius = popupDiagonalLength;
  // ADDED UNTIL HERE
}
Enter fullscreen mode Exit fullscreen mode

where I use the Pythagorean Theorem to obtain the length of a diagonal.

For positioning the ripple, we need the location of its center relative to the top-left corner of the popup (so we can use left and top CSS properties to position the ripple). The location of the user’s click relative to the top-left corner of the browser window is obtained with event.clientX and event.clientY (see Akar 2020 for visual explanation). So the ripple’s center coordinate relative to the top-left corner of the popup is obtained as follows:

const clickHandler = event => {
  const popup = event.currentTarget.offsetParent; 
  const {
    left: popupLeft,
    top: popupTop,
    height: popupHeight,
    width: popupWidth,
  } = popup.getBoundingClientRect();
  const popupDiagonalLength = Math.sqrt(
    Math.pow(popupWidth, 2) + Math.pow(popupHeight, 2),
  );
  const rippleRadius = popupDiagonalLength;

  // ADDED FROM HERE
  const rippleCenter = {
    x: event.clientX - popupLeft,
    y: event.clientY - popupTop,
  };
  // ADDED UNTIL HERE
}
Enter fullscreen mode Exit fullscreen mode

Now we are ready to set the three arguments for the handleClick prop passed to the CloseButton component:

// ./components/CloseButton.js
const clickHandler = event => {
  const popup = event.currentTarget.offsetParent; 
  const {
    left: popupLeft,
    top: popupTop,
    height: popupHeight,
    width: popupWidth,
  } = popup.getBoundingClientRect();
  const popupDiagonalLength = Math.sqrt(
    Math.pow(popupWidth, 2) + Math.pow(popupHeight, 2),
  );
  const rippleRadius = popupDiagonalLength;
  const rippleCenter = {
    x: event.clientX - popupLeft,
    y: event.clientY - popupTop,
  };
  // REVISED FROM HERE
  handleClick({
    rippleDiameter: `${Math.round(rippleRadius * 2)}px`,
    ripplePositionLeft: `${Math.round(rippleCenter.x - rippleRadius)}px`,
    ripplePositionTop: `${Math.round(rippleCenter.y - rippleRadius)}px`,
  });
  // REVISED UNTIL HERE
};
Enter fullscreen mode Exit fullscreen mode

where the ripplePositionLeft and ripplePositionTop are obtained by subtracting the ripple radius from the ripple center’s coordinate.

That’s all for sizing and locating the ripple to fully cover the popup.

2.4 Making the ripple “transparent”

Next, copy the image of the embedded Google Maps beneath the popup onto the ripple’s surface so that the animated ripple will appear like erasing the popup.
A hazy-looking white rectangle on top of Google Maps is cut out with a circular arc from its top rightThe ripple appears like erasing the popup (a screenshot of the demo for this article)

To do so, I revise the DivSearchBackground styled component as follows:

// ./styled-components/DivSearchBackground.js
const revealMapBeneath = `
  &[data-closing='true'] {
    color: black;
    mix-blend-mode: lighten;
  }
  &[data-closing='true'] [id="ripple"] {
    background-color: currentColor;
  }
`;

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

This is different from Tudor (2021) does for erasing a black card:

color: #fff;
mix-blend-mode: darken;
Enter fullscreen mode Exit fullscreen mode

But I have a white popup. The lighten value of mix-blend-mode does the opposite to darken: picking the highest RGB values for each pixel. By specifyiing the text color as black, then, the mix-blend-mode: lighten will pick the RGB values beneath the element, that is, the embedded Google Maps.

2.5 Animating the ripple

Finally, I set animation parameters so the ripple will expand from where the user presses the button into the area covering the entire popup.

As explained in Section 3 of Day 21 of this blog series, I find it easy to work with CSS animation if I put all the parameters together in one place as a Javascript object, with the help of Styled Component's keyframes function:

// ./utils/animation.js
import {keyframes} from 'styled-components';
export const animation = {
  openSearchBox: {
    // Code omitted for brevity (see Day 22 of this blog series)
  },
  closeSearchBox: {} // ADDED
};
Enter fullscreen mode Exit fullscreen mode

The following parameters will scale the ripple from 0% to its full size for the duration of 300ms with the linear easing:

// ./utils/animation.js
import {keyframes} from 'styled-components';
export const animation = {
  openSearchBox: {
    // Code omitted for brevity
  },
  closeSearchBox: {
    // ADDED FROM HERE
    duration: '300ms',
    easing: 'linear',
    ripple: {
      scale: keyframes`
        from {
          transform: scale(0);
        }
        to {
          transform: scale(1);
        }
      `,
      fillMode: 'backwards'
    },
    // ADDED UNTIL HERE
  },
};
Enter fullscreen mode Exit fullscreen mode

where the keyframes helper from Styled Components allows us to store the @keyframes at-rules as a JavaScript variable.

Then I update the SpanRipple styled component as follows:

// ./styled-components/SpanRipple.js
import styled, {css} from 'styled-components'; // REVISED
import {animation} from '../utils/animation'; // ADDED

const shapeRipple = `
  border-radius: 50%;f
`;
const positionRipple = `
  position: absolute;
`;

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

export const SpanRipple = styled.span`
  ${shapeRipple}
  ${positionRipple}
  ${animateRipple} /* ADDED */
`;
Enter fullscreen mode Exit fullscreen mode

By default, any HTML element has transform: scale(1). To avoid this from appearing before animation starts, the animation-fill-mode needs to be set as backwards.

For duration, I’ve settled with 300ms after trial and error. If duration is too short, it is not very noticeable for the popup to be erased by a ripple because animation is too quick. On the other hand, a duration longer than 300ms will prevent the user from moving on to next for too long. The duration of 300ms strikes the right balance between these two concerns.

For easing, I initially tried the decelerated easing used for opening the popup (see the section entitled “Easing” in Day 22 of this blog series). But this caused most of the animation to be too quick for the user to notice the ripple erasing the popup. Linear easing mitigates this problem.

3. Popup

The code that's been written so far will do most of the job. But there are a few other things to make animation neat, by styling the popup.

3.1 Containing the ripple inside the popup

First, I need to contain the ripple inside the popup, by applying overflow: hidden to the popup (that is, DivSearchBackground styled component):

// ./styled-components/DivSearchBackground.js

...

const containRippleWithin = `
  &[data-closing='true'] {
    overflow: hidden;
  }
`; //

export const DivSearchBackground = styled.div`
  ...
  ${revealMapBeneath}
  ${containRippleWithin} /* ADDED */
`;
Enter fullscreen mode Exit fullscreen mode

Without this, the ripple will be overflown beyond the viewport, causing the scroll bars to appear and consequently the layout to shift temporarily.

3.2 Handling cross-browser inconsistency

The code now works beautifully with Safari and Firefox. With Chrome, however, the popup won’t be erased completely: the revealed Google Maps beneath the popup appears blurred until the animation is over.

Apparently, Chrome implements mix-blend-mode differently from Safari and Firefox, when background-filter is also used. On Day 19, I applied the glassmorphism technique to make the popup background appear like clouds. This technique involves the use of background-filter: blur(8px). When implementing mix-blend-mode, Chrome appears to incorporate the background-filter result into the calculation while Safari and Firefox seem to ignore it.

To minimize the impact of seeing the blurred image of Google Maps for Chrome users, therefore, I need to make the popup to fade out while being “erased” by the ripple:

// ./utils/animation.js
import {keyframes} from 'styled-components';
export const animation = {
  openSearchBox: {
    ...
  },
  closeSearchBox: {
    duration: '300ms',
    easing: 'linear',
    // ADDED FROM HERE
    popup: {
      opacity: keyframes`
        0% {
          opacity: 1;
        }
        100% {
          opacity: 0;
        }
      `,
      fillMode: 'forwards'
    },
    // ADDED UNTIL HERE
    ripple: {
      scale: keyframes`
        from {
          transform: scale(0);
        }
        to {
          transform: scale(1);
        }
      `,
      fillMode: 'backwards'
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

And update the DivSearchBackground styled component as follows:

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

// 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

Remember that DivSearchBackground is rendered with the following JSX:

<DivSearchBackground
  data-closing={ui.searchBox === 'closing'}
>
  ...
</DivSearchBackground>
Enter fullscreen mode Exit fullscreen mode

So the [data-closing="true"] attribute selector only applies during the animation of closing the popup.

3.3 Handling the popup edges

So far so good as long as the popup is full-screen or the popup has no noticeable box shadow.

However, for aesthetic reasons, the cloud-like popup for My Ideal Map requires a white box shadow that imitates the edges of clouds (see the section entitled “Edge treatment” in Day 19 of this blog series).

Consequently, when the popup only occupies part of the screen, the ripple will not erase the box shadow of the popup. This is because the ripple is a child element of the popup that is styled with overflow: hidden (see Section 3.1 above).

To solve this issue, I need a wrapper div element that contains the box shadow of the popup. Then, render the ripple as a child of the wrapper element, rather than that of the popup, so that the ripple will cover not only the popup but also its box shadow.

More specifically, I first need to change the JSX code as follows:

<DivSearchBackground.Wrapper                // ADDED
  data-closing={ui.searchBox === 'closing'} // ADDED
>                                          {/* ADDED */}
  <DivSearchBackground
    data-closing={ui.searchBox === 'closing'}
  >
    <CloseButton
      handleClick={handleClickCloseButton}
    />
  </DivSearchBackground>                   {/* MOVED */}
  {ui.searchBox === 'closing' ? (
    <SpanRipple
      id="ripple"
      style={{
        height: ui.rippleDiameter,
        left: ui.ripplePositionLeft,
        top: ui.ripplePositionTop,
        width: ui.rippleDiameter,
      }}
    />
  ) : null}
</DivSearchBackground.Wrapper>             {/* ADDED */}
Enter fullscreen mode Exit fullscreen mode

The wrapper is defined as DivSearchBackground.Wrapper in the file for DivSearchBackground:

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

...

export const DivSearchBackground = styled.div`
  /* Omitted for brevity */
`;

// ADDED FROM HERE
DivSearchBackground.Wrapper = styled.div`
  /* To be styled shortly */
`;
// ADDED UNTIL HERE
Enter fullscreen mode Exit fullscreen mode

This way, the code itself conveys the idea that this wrapper is inseparable from the popup itself. Also, I don’t have to add another import statement: the following line of code

import {DivSearchBackground} from '../styled-components/DivSearchBackground';
Enter fullscreen mode Exit fullscreen mode

will import the wrapper component as well.

Now, the wrapper component is styled as follows:

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

const placeOverMap = `
  position: absolute;
`;
const setOuterSize = `
  bottom: 10%;
  left: 10%;
  right: 10%;
  top: 10%;
`;
const revealMapBeneath = `
  &[data-closing='true'] {
    color: black;
    mix-blend-mode: lighten;
  }
  &[data-closing='true'] [id="ripple"] {
    background-color: currentColor;
  }
`;
const containRippleWithin = `
  overflow: hidden;
`;

export const DivSearchBackground = styled.div`
  /* Omitted for brevity */
`;

DivSearchBackground.Wrapper = styled.div`
  ${placeOverMap}         /* ADDED */
  ${setOuterSize}         /* ADDED */
  ${revealMapBeneath}     /* ADDED */
  ${containRippleWithin}  /* ADDED */
`;
Enter fullscreen mode Exit fullscreen mode

Now the wrapper plays a role of making the ripple copy the image of the embedded Google Maps beneath the popup and containing the ripple inside.

Then, the popup itself is styled as follows:

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

...

const setBackground = `
  --blur-radius: 8px;
  background-color: var(--popup-background-color);
  -webkit-backdrop-filter: blur(var(--blur-radius));
  backdrop-filter: blur(var(--blur-radius));
  box-shadow: 0 0 var(--blur-radius) var(--blur-radius) var(--popup-background-color);

  /* The CSS code to support legacy browsers are omitted */
`;

// ADDED FROM HERE
const setInnerSize = `
  bottom: calc(var(--blur-radius) * 2);
  left: calc(var(--blur-radius) * 2);
  position: absolute;
  right: calc(var(--blur-radius) * 2);
  top: calc(var(--blur-radius) * 2);
`;
// ADDED UNTIL HERE

const animateTransitionIn = css`
  /* Omitted for brevity */
`;
const animateTransitionOut = css`
  &[data-closing="true"] {
    /* Omitted for brevity */
  }
`;

export const DivSearchBackground = styled.div`
  ${setBackground}
  ${setInnerSize}         /* ADDED */
  ${animateTransitionIn}
  ${animateTransitionOut}
`;

DivSearchBackground.Wrapper = styled.div`
  /* Omitted for brevity */
`;
Enter fullscreen mode Exit fullscreen mode

The popup size is now set so that there will be 16px wide space between the wrapper’s (transparent) border and the the popup’s. It ensures that the entire box shadow (whose blur radius and spread radius of 8px each implies its width of 16px) is included inside the wrapper. (See my article Kudamatsu (2020) for how the blur radius and spread radius of box-shadow shapes the shadow length).

This way, the ripple will erase the entire popup including its blurry white edges.

4. Search button

To animate the reappearance of the button to open the search box popup, apply the keyframes to simply fade in:

// ./utils/animation.js
import {keyframes} from 'styled-components';
export const animation = {
  openSearchBox: {
    // omitted for brevity
  },
  closeSearchBox: {
    duration: '300ms',
    easing: 'linear',
    // ADDED FROM HERE
    button: {
      opacity: keyframes`
        0% {
          opacity: 0; 
        }
        100% {
          opacity: 1;
        }
      `,
      fillMode: 'backwards',
    },
    // ADDED UNTIL HERE
    popup: {
      // Omitted for brevity
    },
    ripple: {
      // Omitted for brevity
    }
  }
};
Enter fullscreen mode Exit fullscreen mode
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

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

`;

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

5. Reduced motion preference

For uses who prefer the reduced motion mode, we simply fade out the popup and fade in the search button, removing the ripple effect.

Once the ripple effect is removed, however, the duration of 300ms is slightly too long. We shorten it to 250ms.

So we replace the duration parameter for the reduced motion preference:

// ./utils/animation.js
...
export const animation = {
  openSearchBox: {
    // omitted for brevity
  },
  closeSearchBox: {
    duration: '300ms',
    easing: 'linear',
    button: {
      // omitted for brevity
    },
    popup: {
      opacity: keyframes`
        0% {
          opacity: 1;
        }
        100% {
          opacity: 0;
        }
      `,
      fillMode: 'forwards'
    },
    ripple: {
      // omitted for brevity
    },
    // ADDED FROM HERE
    reducedMotion: {
      duration: '250ms',
    }
    // ADDED UNTIL HERE
  },
};
Enter fullscreen mode Exit fullscreen mode

Accordingly, I revise the styled components as follows. For the ripple, I simply remove the animation and keep the ripple scaled to be zero so that the user will not see it at all:

// ./styled-components/SpanRipple.js
...
const animateRipple = css`
  animation-duration: ${animation.closeSearchBox.duration};
  animation-fill-mode: ${animation.closeSearchBox.ripple.fillMode};
  animation-name: ${animation.closeSearchBox.ripple.scale};
  animation-timing-function: ${animation.closeSearchBox.easing};
  /* ADDED FROM HERE */
  @media (prefers-reduced-motion: reduce) {
    animation-name: none;
    transform: scale(0);
  }
  /* ADDED UNTIL HERE */
`;
...
Enter fullscreen mode Exit fullscreen mode

For the button to open the search box, I override the duration:

// ./styled-components/ButtonCloud.js
...
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 FROM HERE */
  @media (prefers-reduced-motion: reduce) {
    animation-duration: ${animation.closeSearchBox.reducedMotion.duration};
  }
  /* ADDED UNTIL HERE */
`;
...
Enter fullscreen mode Exit fullscreen mode

The same applies to the popup:

// ./styled-components/DivSearchBackground.js
...
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 FROM HERE */
    @media (prefers-reduced-motion: reduce) {
      animation-duration: ${animation.closeSearchBox.reducedMotion.duration};
    }
    /* ADDED UNTIL HERE */
  }
`;
// ADDED UNTIL HERE
...
Enter fullscreen mode Exit fullscreen mode

Demo

The entire code is available as the CodeSandbox demo for this article.

References

Akar, Soner (2020) “I don’t like and understand things, which can be explained visually, by words”, Stack Overflow, Aug 27, 2020.

Cameron, Bret (2020) “How to Recreate the Ripple Effect of Material Design Buttons”, CSS-Tricks, Oct 12, 2020.

Chikuyonok, Sergey (2016) “CSS GPU Animation: Doing It Right”, Smashing Magazine, Dec 9, 2016.

Kravets, Una (2021) “Updates in hardware-accelerated animation capabilities”, Chrome Developers, Feb 22, 2021.

Kudamatsu, Masa (2020) “CSS box shadow is not to create a shadow…”, Web Dev Survey from Kyoto, Jan 3, 2020.

Rasmussen, Daniel (2019) “Close Tiles”, dribbble, 2019.

Tudor, Ana (2021) “Close tiles”, CodePen, Feb 26, 2021.

Top comments (0)