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

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

--

Button ripple effect is wiping away a white popup (a screenshot of the demo for this article)

TL;DR

Suppose you have the following DOM structure for a popup:

<div class="popup" data-closing='false'>
<span class="ripple"></span>
</div>

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

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).

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 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 colorand mix-blend-mode properties as follows:

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

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

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

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',
});

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',
});
};

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>

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

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>

When the UI state for the search box popup becomes closing, the SpanRipple 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>
)
};

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

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

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
}

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
}

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

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.

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

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

color: #fff;
mix-blend-mode: darken;

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

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

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%;
`;
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 */
`;

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 */
`;

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

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 */
`;

Remember that DivSearchBackground is rendered with the following JSX:

<DivSearchBackground
data-closing={ui.searchBox === 'closing'}
>
...
</DivSearchBackground>

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

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

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

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 */
`;

The wrapper plays a role of making the ripple copy the image of the embedded Google Maps beneath the popup. It also contains 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 */
`;

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.

And as a bonus, the Chrome’s “bug” mentioned in Section 3.2 above disappears, the reason of which is unclear… I still keep the fade-out animation for the popup itself, though, because it softens the otherwise slightly harsh appearance of the popup being wiped away by the ripple.

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
}
}
};
// ./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 */
`;

I use the same duration and easing parameter values as for closing the search box popup, to ensure that the whole animation appears well-coordinated.

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

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 */
`;
...

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 */
`;
...

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

Finally, we’re done!

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.

--

--

MasaKudamatsu
100 Days in Kyoto to Create a Web App with Google Maps API

Self-taught web developer (currently in search of a job) whose portfolio is available at masakudamatsu.dev