Modern web browsers can animate transform very cheaply; animating any other property will most likely affect the FPS.

The performance of an expand/collapse animation depends on which properties we animate: width, height require layout and paint at each frame, while clip requires paint and having an absolute or fixed positioning for the content.

Instead, we can animate scale transforms and prevent the skewing of the content by counter-scaling it, and enjoy a hardware accelerated animation ⚡️

What Are We Building?

We will implement a dropdown menu and use scale transforms for its expand/collapse animations.

What You'll Learn

How would you describe your level of experience with web development?

Novice Intermediate Proficient

How will you use this tutorial?

Only read through it Read it and complete the exercises

Download the Code

Clone the repository from GitHub:

git clone https://github.com/PolymerLabs/expand-collapse-animations.git

Alternatively, click the following button to download the latest version of the repository:

Download source code

The expand-collapse-animations (or expand-collapse-animations-master) folder contains a work directory with the starting point of this codelab. There is also a folder for the expected end state of each step of this codelab provided for your reference.

Navigate to the folder you cloned or downloaded:

cd expand-collapse-animations

Or:

cd expand-collapse-animations-master

Install Tools

  1. Install Git
  2. Install Node.js
  3. Install the latest version of Bower
npm install -g bower
  1. Install Polymer CLI
npm install -g polymer-cli

Install Bower Dependencies

Navigate to the work directory:

cd work

Then install bower dependencies:

bower install

This will install the latest version of Polymer into bower_components.

Use polymer-cli to Serve Your Element

From the work directory, run the following command to start a web server for your element and load it in a browser:

polymer serve --open

dropdown-menu.html already provides basic support for opening and closing the menu on user interactions. It also provides custom css properties to position the content. We want to add an expand animation when it is opened and collapse animation when it is closed.

The approach we'll use consists in clipping the content by using the css property overflow: hidden on the container. To avoid scaling the content of the menu, which undesirably distorts text, we'll use a counter-scale transform on the content. E.g. for a vertical expand animation, all we have to do is animate the container from transform: scaleY(0.1) to transform: scaleY(1), and counter-scale the content from transform: scaleY(10) to transform: scaleY(1).

The expand animation consists of 2 effects: fade in (opacity from 0 to 1) and expand (scale from 0.5 to 1). For the collapse animation we'll be playing these effects in reverse.

In the template, we listen for the animationend event on the contentWrapper

dropdown-menu.html

<div id="contentWrapper" on-animationend="_onAnimationend">
  <slot></slot>
</div>

We'll use the animating class to better coordinate the animations. We add the class every time opened changes, and remove it when the animation is done. Update the script as follows:

dropdown-menu.html

_openedChanged() {
  //...
  this.$.contentWrapper.classList.add('animating');
}

_onAnimationend(event) {
  if (event.target === this.$.contentWrapper) {
    this.$.contentWrapper.classList.remove('animating');
  }
}

Next, let's define the fadeIn and expand animations. We'll leverage the opened class to reverse the animation direction, which gets us the collapse animation for free 🎉 Also, we'll need to keep the contentWrapper visible while the animation is playing. Finally, we'll add 2 new css custom properties to style transform-origin and animation-duration.

dropdown-menu.html

#contentWrapper {
  z-index: 1;
  position: absolute;
  top: var(--dropdown-menu-content-top, 0);
  left: var(--dropdown-menu-content-left, 0);
  bottom: var(--dropdown-menu-content-bottom, auto);
  right: var(--dropdown-menu-content-right, auto);
  background: var(--dropdown-menu-content-background, white);
  box-shadow: var(--dropdown-menu-box-content-shadow, 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2));
  
  overflow: hidden;
  contain: content;
  will-change: opacity, transform;
  transform-origin: var(--dropdown-menu-content-transform-origin, top left);
  animation-duration: var(--dropdown-menu-content-animation-duration, 200ms);
}

#contentWrapper:not(.opened):not(.animating) {
  display: none;
}

#contentWrapper.animating {
  animation-name: fadeIn, expand;
}

#contentWrapper:not(.opened) {
  animation-direction: reverse;
}

@keyframes fadeIn {
  from { opacity: 0 }
}

@keyframes expand {
  from { transform: scale(0.5, 0.5) }
}

Let's use the 2 new css custom properties in index.html, and update the styles:

index.html

dropdown-menu.top-right {
  --dropdown-menu-content-left: auto;
  --dropdown-menu-content-right: 0;
  --dropdown-menu-content-transform-origin: top right;
}

dropdown-menu.bottom-right {
  --dropdown-menu-content-left: auto;
  --dropdown-menu-content-right: 0;
  --dropdown-menu-content-top: auto;
  --dropdown-menu-content-bottom: 0;
  --dropdown-menu-content-transform-origin: bottom right;
}

dropdown-menu.top-center {
  --dropdown-menu-content-left: calc(50% - 80px);
  --dropdown-menu-content-transform-origin: top center;
  --dropdown-menu-content-animation-duration: 500ms;
}

Try opening the dropdown menus, and notice how the content gets scaled while opening and closing. You can slow down the animations in the Animations panel of DevTools, or increasing the animation duration.

To fix this, we'll need to counter-scale the content.

Let's wrap the slot inside contentWrapper with another div: it will be the element where we'll apply the counter-scale. Update dropdown-menu.html:

dropdown-menu.html

<div id="contentWrapper" on-animationend="_onAnimationend">
  <div>
    <slot></slot>
  </div>
</div>

Let's define the counter-scale animation as expandInverse, which will go from scale(2, 2) to scale(1, 1). We won't need to fade in the inner div as we already fade contentWrapper. Let's add these styles:

dropdown-menu.html

#contentWrapper > div {
  contain: content;
  will-change: transform;
  transform-origin: var(--dropdown-menu-content-transform-origin, top left);
  animation-duration: var(--dropdown-menu-content-animation-duration, 200ms);
}

#contentWrapper.animating > div {
  animation-name: expandInverse;
}

#contentWrapper:not(.opened) > div {
  animation-direction: reverse;
}

/* ... */

@keyframes expandInverse {
  from { transform: scale(2, 2) }
}

If we now try toggling any of the dropdown menus, we'll notice a strange scaling effect happening in the content. This is caused by incorrect values calculated by the keyframes interpolation.

At 50% of the expand animation, contentWrapper scale will be 0.5 + (1 - 0.5) * 0.5 = 0.75, while the inner div scale will be 2 - (2 - 1) * 0.5 = 1.5, instead of 1/0.75 = 1.333.

To avoid the scaling effect, we need to disable the keyframe interpolation through the animation-timing-function. Let's update the style as follows:

dropdown-menu.html

#contentWrapper.animating {
  animation-name: fadeIn, expand;
  animation-timing-function: linear, step-end;
}

#contentWrapper.animating > div {
  animation-name: expandInverse;
  animation-timing-function: step-end;
}

If we now toggle the dropdown menus, we'll notice janky animations, and that's caused by the low number of keyframes we provided - just one, actually 😅.

The number of keyframes and the animation duration will determine the FPS of our animation.

The top-center dropdown menu animates for 500ms; to have a 60FPS animation we'd need to generate 60 keyframes (30 for expand and 30 for expandInverse).

We can use the animation-keyframes editor to generate enough keyframes for a 500ms expand animation at 60 FPS. Copy the generated keyframes and update the style in dropdown-menu.html

dropdown-menu.html

@keyframes expand {
  0% { transform: scale(0.5, 0.5); }
  3.44828% { transform: scale(0.56548, 0.56548); }
  /*...*/
  96.55172% { transform: scale(1, 1); }
  100% { transform: scale(1, 1); }
}

@keyframes expandInverse {
  0% { transform: scale(2, 2); }
  3.44828% { transform: scale(1.76841, 1.76841); }
  /*...*/
  96.55172% { transform: scale(1, 1); }
  100% { transform: scale(1, 1); }
}

The nice thing about this approach is that it is content-agnostic: the content can change even during the animation, and we'd still have a buttery-smooth transition. In index.html, try wrapping the items in a <div contenteditable>, and edit its content when the dropdown is open:

index.html

<dropdown-menu>
  <button slot="trigger">top-center</button>
  <div contenteditable>
    <div class="item">item 1</div>
    <div class="item">item 2</div>
    <div class="item">item 3</div>
    <div class="item">item 4</div>
    <div class="item">item 5</div>
  </div>
</dropdown-menu>

Dynamic keyframe calculation

You can use <animation-keyframes> to generate keyframes on-the-fly, in situations where the starting or ending scales are dynamic; for example, if the content size changes. See <expand-collapse> element for an example (implementation here).