Day 22: Using container transform pattern to animate the appearance of a search box

masakudamatsu

Masa Kudamatsu

Posted on January 3, 2023

Day 22: Using container transform pattern to animate the appearance of a search box

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.

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

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) 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 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) 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, 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.
** (image source: Google (undated))**

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:

// ./utils/animation.js
export const animation = {
  openSearchBox: {
    duration: '300ms',
  },
};
Enter fullscreen mode Exit fullscreen mode

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

// ./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}
`;
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode

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 specify “80% incoming, 40% outgoing”. According to the archived version of Material Design guidelines, this is achieved with the following CSS declaration:

animation-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1);
Enter fullscreen mode Exit fullscreen mode

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); the search box popup has the background of cloud-like texture (see Day 19 of this blog series); 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), 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):

animation-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1);
Enter fullscreen mode Exit fullscreen mode

So in the JavaScript object that stores all the animation parameters, I add the following:

// ./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
  },
};
Enter fullscreen mode Exit fullscreen mode

And add the animation-timing-function properties to the two styled components:

// ./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}
`;
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode

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:

// ./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
  },
};
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode

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:

// ./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
  },
};
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode

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:

transform-origin: "top right";
Enter fullscreen mode Exit fullscreen mode

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

// ./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', 
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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:

// ./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}
`;
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode

Reduced motion preference

Finally, to cater to those users who have configured their OS in the reduced motion mode (see Steiner 2019 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.

// ./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
  },
};
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode
// ./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}
`;
Enter fullscreen mode Exit fullscreen mode

Demo

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

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”, CodePen, Jun 7, 2021.

Feldman, Julia, Brenton Simpson, Jonas Naimark, and Michael Gilbert (2022) “Choosing the Right Transitions”, Material Design Blog, Jan 20, 2022.

Google (undated) “The motion system”, Material Design, undated.

Liew, Zell (2017) “CSS Transitions explained”, zellwk.com, Dec 13, 2017.

Sherwin, Katie (2014) “The Magnifying-Glass Icon in Search Design: Pros and Cons”, Nielsen Norman Group, Feb 23, 2014.

Steiner, Thomas (2019) “prefers-reduced-motion: Sometimes less movement is more”, web.dev, Mar 11, 2019.

💖 💪 🙅 🚩
masakudamatsu
Masa Kudamatsu

Posted on January 3, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related