Day 22: Using container transform pattern to animate the appearance of a search box
Tags: css, animation, webdev
Published: 2023-01-03 03:25:15

## TL;DR
This article describes a simple way of implementing Material Design’s _container transform_ animation with `animation` and `transform-origin` CSS properties. More specifically, the opening of a search box by pressing the magnyfing glass button will be animated to create an impression that the button expands and morphs into a popup with the search box.

Here is [the CodeSandbox demo for this blog post](https://codesandbox.io/s/day-22-animating-the-opening-of-a-search-box-with-container-transform-ish-pattern-j1u0fz?file=/src/utils/animation.js).

![The user presses the magnifying glass icon button at the top-right corner of the screen; then the button scales up towards bottom right and transforms itself into a search box popup at the centre of the screen.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3qjk5b3d7l12g4s60ir8.gif)
**The animation this article will implement (screen-captured by the author)**

## Introduction

_My Ideal Map_, a web app I’m building to improve the UX of Google Maps, hides a search box by default, to allow the user to discover the saved places of their interest that would otherwise be hidden beneath the search box.

To search for a place, the user first needs to press the magnifying glass icon button to reveal a search box. And I want to animate this user interface transition with [Material Design’s _container transform_ pattern](https://pub.dev/packages/animations#container-transform).

Below I describe how I implement it with CSS, after explaining the rationale behind the choice of the _container transform_ pattern.

## Why Container Transform?
There are three reasons why I go for the _container transform_ pattern to animate the appearance of a search box.

### Proximity
In discussing the magnifying glass icon button to open a search box, [Sherwin (2014)](https://www.nngroup.com/articles/magnifying-glass-icon/) recommends the following:
> When users click on the search icon, display the text-entry field close to that icon. Placing it far away increases the interaction cost for the user by forcing them to find the box before they can start typing. It also goes against the Gestalt law of proximity to show related items far from each other.

The _container transform_ pattern creates an impression that the incoming element comes out of the outgoing element, establishing “proximity” in a dynamic sense. 

### Parent-child relationship

[Material Design guidelines](https://m2.material.io/design/navigation/navigation-transitions.html#hierarchical-transitions) recommend using the _container transform_ pattern to reinforce the parent-child relationship between the outgoing and incoming elements. 

A search box is not a brother or sister of the search button, certainly not unrelated to it. It is a parent-child relationship, and the _container transform_ pattern conveys this relationship to the user.

### Warmth
[Feldman et al. (2022)](https://material.io/blog/motion-research-container-transform) report the results of their UX research on animated transitions. Participants were asked to use a food ordering app, tapping the thumbnail of a dish to see its detail and the button to order it. 

Among eight animation patterns for the transition from the thumbnail to the detail view, the researchers found that a clear majority of users preferred the _container transform_ pattern. Interestingly, many of them described it as “warm and inviting”.

The app I’m making, _My Ideal Map_, aims to evoke such nice feelings in the user’s mind. Its design concept is “Dye me in your hue from the sky”. It is about the personalization of Google Maps. It is about adding color to the otherwise bland canvas of Google Maps by dropping the place marks of the user’s own interest.

It is therefore desirable to enhance the warmth of user interface, with the _container transform_ animation pattern.

## Implementation with CSS
On [**Day 21 of this blog series**](https://dev.to/masakudamatsu/day-21-coding-transition-animation-with-react-from-scratch-244f), I wrote up the React code to animate transitions between the search button and the search box popup. Building on this code base, I can simply set CSS animation properties to implement the _container transform_ pattern.

### Fade-through variant
I apply the “fade through variant” of the container transform pattern from Material Design:
![Outgoing elements fade out during the first 90ms; incoming elements fade in during the subsequent 210ms; elements scale to width and pin to top of container for the entire duration of 300ms.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pccax4rpqveaaclmhump.png)
** (image source: [Google (undated)](https://m2.material.io/design/motion/the-motion-system.html#transition-patterns))**

As indicated in the above diagram, the duration of animation is 300ms. In the first 90ms, the outgoing elements fade out. In the remaining 210ms, the incoming elements fade in. At the same time, both elements scale up for the entire duration of animation. 

This way, it creates an impression that a small container element, once pressed by the user, expands and turns into a large element with detailed information. Separating the fading-out of the outgoing elements from the fading-in of the incoming elements makes sure that the animation will not appear jarring with the overlaps of the two sets of elements.

Translating this logic into CSS code is a bit tricky. To organize how to specify animation parameters, I use Styled Components in the following manner.

### Duration

Let’s first set the duration of the entire animation.

First, I create a JavaScript object in which all the animation parameters are defined in one place:
```js
// ./utils/animation.js
export const animation = {
  openSearchBox: {
    duration: '300ms',
  },
};
```
where I define the duration for the entire animation for opening the search box as `300ms`.

Then, I refer to this JavaScript object in the styled components for the search button (`ButtonCloud`) and the search box popup (`DivSearchBackground`):
```js
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionOut = css`
  &[data-closing="true"] {
    animation-duration: ${animation.openSearchBox.duration};
  }
`;

export const ButtonCloud = styled.button`
  ${animateTransitionOut}
`;
```
```js
// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionIn = css`
  animation-duration: ${animation.openSearchBox.duration};
`;

export const DivSearchBackground = styled.div`
  ${animateTransitionIn}
`;
```
where I omit the code for static styling of these two components.

Below I’m going to add more code for CSS animation to these three files.

This way, I don’t have to go through both of the styled components to understand how the two elements coordinate to achieve the _container transform_ animation. I just need to see the `animation.js` file.

### Easing
For easing to be used for the _container transform_ animation, [Material Design guidelines](https://m2.material.io/design/motion/the-motion-system.html#container-transform) specify “80% incoming, 40% outgoing”. According to [the archived version of Material Design guidelines](https://m1.material.io/motion/duration-easing.html#duration-easing-natural-easing-curves), this is achieved with the following CSS declaration:
```css
animation-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1);
```

This easing creates the impression of a machine. When we turn on a machine, it slowly starts up and then accelerates. When we turn it off, it decelerates before it completely stops. 

For the web app I’m building, however, such a mechanical impression isn’t appropriate. Here is why.

The magnifying glass button is shaped like a cloud (see [**Day 7 of this blog series**](https://dev.to/masakudamatsu/day-7-making-buttons-look-like-clouds-for-embedded-google-maps-33po)); the search box popup has the background of cloud-like texture (see [**Day 19 of this blog series**](https://dev.to/masakudamatsu/day-19-styling-a-popup-like-clouds-2m04)); both of these design decisions are based on the likening of Google Maps as the view of a city from the sky.

Consequently, I want to imitate the movement of clouds, interacted with a human finger, rather than the one of machines.

So what kind of easing is appropriate? 

It should be like the behavior of a ball thrown by a human. When a human throws a ball, the ball immediately moves at high speed. Then it gradually slows down until it completely stops. (I owe this metaphor to [Liew (2017)](https://zellwk.com/blog/css-transitions/), who helped me understand how to choose the easing pattern for animation.)

I imagine if we were able to hit a cloud with our finger, the cloud would move away from the finger tip in a similar fashion: initially moves away at high speed and then gradually slows down.

In Material Design, this pattern of easing is called _decelerated easing_, achieved with the following CSS declaration (source: [Material Design guidelines](https://m2.material.io/design/motion/speed.html#easing)):
```css
animation-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1);
```

So in the JavaScript object that stores all the animation parameters, I add the following:
```js
// ./utils/animation.js
import {keyframes} from 'styled-components';

export const animation = {
  openSearchBox: {
    duration: '300ms',
    easing: 'cubic-bezier(0.0, 0.0, 0.2, 1)', // ADDED
  },
};
```
And add the `animation-timing-function` properties to the two styled components:
```js
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionOut = css`
  &[data-closing="true"] {
    animation-duration: ${animation.openSearchBox.duration};
    animation-timing-function: ${animation.openSearchBox.easing}; /* ADDED */
  }
`;

export const ButtonCloud = styled.button`
  ${animateTransitionOut}
`;
```
```js
// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionIn = css`
  animation-duration: ${animation.openSearchBox.duration};
  animation-timing-function: ${animation.openSearchBox.easing}; /* ADDED */
`;

export const DivSearchBackground = styled.div`
  ${animateTransitionIn}
`;
```

Using the common easing parameter, both the outgoing and incoming elements will be animated in a coordinated manner. 

### Opacity animation
Now, let’s work on the opacity part of the animation. According to the fade-through variant of the container transform pattern described earlier, the outgoing element disappears during the first 90ms. For this to be happening, I use `keyframes` to finish the animation at `30%`, that is, 90ms divided by 300ms:
```js
// ./utils/animation.js
import {keyframes} from 'styled-components'; // ADDED

export const animation = {
  open: {
    duration: '300ms',
    easing: 'cubic-bezier(0.0, 0.0, 0.2, 1)',
    // ADDED FROM HERE
    button: {
      opacity: keyframes`
        30%, /* mocking 90ms duration */
        100% {
          opacity: 0; 
        }
      `,
      fillMode: 'forwards',
    },
    // ADDED UNTIL HERE
  },
};
```
```js
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionOut = css`
  &[data-closing="true"] {
    animation-duration: ${animation.openSearchBox.duration}; 
    animation-fill-mode: ${animation.openSearchBox.button.fillMode}; /* ADDED */
    animation-name: ${animation.openSearchBox.button.opacity}; /* ADDED */
    animation-timing-function: ${animation.openSearchBox.easing};
  }
`;

export const ButtonCloud = styled.button`
  ${animateTransitionOut}
`;
```
I set the value of `animation-fill-mode` to be `forwards`. Without this, as soon as animation is over, the opacity goes back to the default value of 1.

Before explaining why I don’t set `animation-duration` to be 90ms, let me first set the opacity animation for the search box popup:
```js
// ./utils/animation.js
import {keyframes} from 'styled-components';

export const animation = {
  open: {
    duration: '300ms',
    easing: 'cubic-bezier(0.0, 0.0, 0.2, 1)',
    button: {
      opacity: keyframes`
        30%, /* mocking 90ms duration */
        100% {
          opacity: 0; 
        }
      `,
      fillMode: 'forwards',
    },
    // ADDED FROM HERE
    popup: {
      opacity: keyframes`
        0%,
        30% { /* mocking 90ms delay */
          opacity: 0;
        }
        100% {
          opacity: 1;
        }
      `,
      fillMode: 'backwards', 
    },
    // ADDED UNTIL HERE
  },
};
```
```js
// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

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

export const DivSearchBackground = styled.div`
  ${animateTransitionIn}
`;
```
For the incoming element, its opacity animation should be delayed with 90ms. This delay is achieved by setting the opacity to be 0 until 30% of the duration.

This time, the value of `animation-fill-mode` must be `backwards`; otherwise the default opacity of 1 will apply at the beginning of the animation. For the incoming element’s animation to be delayed, this is essential.

Now, if I used `animation-delay` to delay the animation for the incoming element and specified `animation-duration` to be `90ms` for the outgoing element, animation would slow down towards the end of the first 90ms and then suddenly speeds up after 90ms have passed because I have set the easing to be decelerated. 

To control the speed of animation for both outgoing and incoming elements in a harmonious way, I've figured out that it is best to use `keyframes` to finish animation early for the outgoing element and to delay animation for the incoming element, along with the same duration and easing pattern for both elements.

### Scaling

Now let’s turn to the scaling part of the animation.

To create an impression that the search button at the top-right corner of the screen morphs into the search box popup, the scaling animation needs to be anchored at the top right corner of elements. This is done with [the `transform-origin` property](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-origin):
```css
transform-origin: "top right";
```

A footnote: instead of using `transform-origin`, a more proper implementation of the _container transform_ animation is to use `translateX()` and `translateY()` CSS functions. See the CodePen by [Ainalem (2021)](https://codepen.io/ainalem/pen/KKWeBMz) for an example. In our case, however, I feel the `transform-origin` approach is good enough.

Armed with `transform-origin`, the following parameters will create a simple version of the _container transform_ animation:
```js
// ./utils/animation.js
import {keyframes} from 'styled-components';

export const animation = {
  open: {
    duration: '300ms',
    easing: 'cubic-bezier(0.0, 0.0, 0.2, 1)',   
    origin: 'top right', // ADDED
    button: {
      opacity: keyframes`
        30%, /* mocking 90ms duration */
        100% {
          opacity: 0; 
        }
      `,
      // ADDED FROM HERE
      scale: keyframes`
        100% {
          transform: scale(4);
        }
      `,
      // ADDED UNTIL HERE
      fillMode: 'forwards',
    },
    popup: {
      opacity: keyframes`
        0%,
        30% { /* mocking 90ms delay */
          opacity: 0;
        }
        100% {
          opacity: 1;
        }
      `,
      // ADDED FROM HERE
      scale: keyframes`
        0% {
          transform: scale(0);
        }
        100% {
          transform: scale(1);
        }
      `,
      // ADDED UNTIL HERE
      fillMode: 'backwards', 
    },
  },
};
```
While the search box popup scales up from zero to its full size, the search button scales up by four times by the end of the animation. With the value of four, the whole animation appears to be seamless (which I’ve found out after trial and error).

Now apply these parameter values to the two styled components:
```js
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

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.openSearchBox.button.scale}; /* REVISED */
    animation-timing-function: ${animation.openSearchBox.easing};
    transform-origin: ${animation.openSearchBox.origin}; /* ADDED */
  }
`;

export const ButtonCloud = styled.button`
  ${animateTransitionOut}
`;
```
```js
// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionIn = css`
  animation-duration: ${animation.openSearchBox.duration};
  animation-fillMode: ${animation.openSearchBox.popup.fillMode}; 
  animation-name: 
    ${animation.openSearchBox.popup.opacity}, 
    ${animation.openSearchBox.popup.scale}; /* REVISED */
  animation-timing-function: ${animation.openSearchBox.easing};
  transform-origin: ${animation.openSearchBox.origin}; /* ADDED */
`;

export const DivSearchBackground = styled.div`
  ${animateTransitionIn}
`;
```

### Reduced motion preference
Finally, to cater to those users who have configured their OS in the _reduced motion_ mode (see [Steiner 2019](https://web.dev/prefers-reduced-motion/) for a good introduction), I remove the scaling animation by overriding the `animation-name` property values. I also simplify the opacity animation: there is no longer necessary to delay the incoming element appearance or to finish early the outgoing element disappearance.
```js
// ./utils/animation.js
import { keyframes } from "styled-components";
export const animation = {
  openSearchBox: {
    duration: "300ms",
    easing: "cubic-bezier(0.0, 0.0, 0.2, 1)", 
    origin: "top right",
    button: {
      // Omitted for brevity
    },
    popup: {
      // Omitted for brevity
    },
    // ADDED FROM HERE
    reducedMotion: {
      button: {
        opacity: keyframes`
          0% {
            opacity: 1;
          }
          100% {
            opacity: 0;
          }
        `
      },
      popup: {
        opacity: keyframes`
          0% {
            opacity: 0;
          }
          100% {
            opacity: 1;
          }
        `
      }
    },
    // ADDED UNTIL HERE
  },
};
```
```js
// ./styled-components/ButtonCloud.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

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.openSearchBox.button.scale};
    animation-timing-function: ${animation.openSearchBox.easing}; 
    transform-origin: ${animation.openSearchBox.origin};
    /* ADDED FROM HERE */
    @media (prefers-reduced-motion: reduce) {
      animation-name: ${animation.openSearchBox.reducedMotion.button.opacity};
    }
    /* ADDED UNTIL HERE */
  }
`;

export const ButtonCloud = styled.button`
  ${animateTransitionOut}
`;
```
```js
// ./styled-components/DivSearchBackground.js
import styled, {css} from 'styled-components';
import {animation} from '../utils/animation';

const animateTransitionIn = css`
  animation-duration: ${animation.openSearchBox.duration};
  animation-fillMode: ${animation.openSearchBox.popup.fillMode}; 
  animation-name: ${animation.openSearchBox.popup.opacity}, ${animation.openSearchBox.popup.scale};
  animation-timing-function: ${animation.openSearchBox.easing};
  transform-origin: ${animation.openSearchBox.origin}; 
  /* ADDED FROM HERE */
  @media (prefers-reduced-motion: reduce) {
    animation-name: ${animation.openSearchBox.reducedMotion.popup.opacity};
  }
  /* ADDED UNTIL HERE */
`;

export const DivSearchBackground = styled.div`
  ${animateTransitionIn}
`;
```

### Demo
The entire code is available in [the CodeSandbox demo for this blog post](https://codesandbox.io/s/day-22-animating-the-opening-of-a-search-box-with-container-transform-ish-pattern-j1u0fz?file=/src/utils/animation.js).

## Animation for closing the search box
That’s the topic for the next post of this blog series. Watch this space!

## References
Ainalem, Mikael (2021) “[Books](https://codepen.io/ainalem/pen/KKWeBMz)”, _CodePen_, Jun 7, 2021.

Feldman, Julia, Brenton Simpson, Jonas Naimark, and Michael Gilbert (2022) “[Choosing the Right Transitions](https://material.io/blog/motion-research-container-transform)”, _Material Design Blog_, Jan 20, 2022.

Google (undated) “[The motion system](https://m2.material.io/design/motion/the-motion-system.html)”, _Material Design_, undated.

Liew, Zell (2017) “[CSS Transitions explained](https://zellwk.com/blog/css-transitions/)”, _zellwk.com_, Dec 13, 2017.

Sherwin, Katie (2014) “[The Magnifying-Glass Icon in Search Design: Pros and Cons](https://www.nngroup.com/articles/magnifying-glass-icon/)”, _Nielsen Norman Group_, Feb 23, 2014. 

Steiner, Thomas (2019) “[prefers-reduced-motion: Sometimes less movement is more](https://web.dev/prefers-reduced-motion/)”, _web.dev_, Mar 11, 2019.