Tutorial hero image
Lesson icon

High Performance Animated Accordion List in Ionic

9 min read

Originally published August 12, 2020

A couple of years ago I published some tutorials on building an accordion style list in Ionic. The method covered in that tutorial works well enough, but in this tutorial I will be taking another look at building an accordion list, this time with a non-comprising approach to creating a high performance accordion component.

You can see this in action here.

The main issue with building an animated accordion component is that you would naturally animate the height of the content that is being opened in the accordion list. This is what gives it that "accordion" feel. One item expands pushing the other down, and then when it is closed it collapses all of the items below it back again. However, the problem with animating height is that it is bad for performance. Animating height will trigger browser "layouts" as items are pushed around the screen and need to have their positions recalculated. This is an expensive process for the browser, especially if it needs to do it a lot (e.g. as it does when animating the height of an item in an accordion list).

In some scenarios, animating height might still keep your application peformant enough to be acceptable, but if we want to animate whilst maintaining a high degree of performance we should focus on animating only the transform property for this kind of behaviour. That's easier said than done, though. The good thing about a transform is that it only impacts the element being transformed, meaning that the positions of other elements on the screen won't be impacted and so the browser doesn't need to perform expensive layout recalculations. The bad thing for our scenario is that we want the other items in the list to be impacted - when one item is opened, all the other items need to move down the screen.

To solve this catch-22 situation, we use a trick that I also made use of in Advanced Animations & Interactions with Ionic to create a high performance delete animation.

Outline

Source code

The Trick

Before we get into the code for this I want to highlight how the concept works in general, otherwise things might get a bit confusing. Here's the general process for how opening an accordion item will work:

  1. An accordion item is clicked
  2. The content for the item is displayed immediately (no animation)
  3. Every item below the item being opened is transformed up so that it hides the content that was just displayed (at this point, there will be no noticeable change on screen, because the items were just transformed back into the position that they were at initially)
  4. The elements that were just transformed up have the transforms animated away. This will cause them to slide down to reveal the content that was just displayed.

By translating the position of all of the items below the one being opened, we can give the appearance of the height of the content being animated, but really everything else below it is just being moved out of the way with a transform. The one remaining issue with this is that since we rely on the elements below the one being opened to initially block the content from being visible, we run into a problem when either:

  1. The last element in the accordion list is being opened (it won't have anything to block the content, so the content will jsut appear immediately and won't be animated)
  2. The content for an item earlier in the accordion list is long enough that it extends past the bottom of the list anyway, in which case we will see the content leaking out of the bottom of the list.

To handle this, we create an invisible "blocker" element that sits at the bottom of the list, and will change its height dynamically to make sure it is large enough to block any content from being visible (e.g. if the item being opened has content that is 250px high, the blocker will dynamically be set to a height of 250px). If you're thinking - hey! you said we weren't going to use height - the important difference here is that we are not animating the height, it will just instantly be set to whatever value we need.

To make this blocker "invisible" we have to set it to be the same colour as the background, which creates one weird limitation for this component that it can only be used on pages with a solid background colour (e.g. not a gradient or image).

Even with this overview description, I still think the concept is a bit confusing. To help, I've created a diagram of what this process actually looks like. I have given the "blocker" element an obvious colour, and reduced the opacity of the items so that we can better see what is going on behind the scenes:

Diagram of how the accordion list open animation works

In this example, the blocker isn't actually necessary because we are opening the second item in the accordion list and the content is not long enough to extend past the bottom of the list. However, if this item was one of the last two items the blocker would come into play to hide the content.

Once you understand this process, closing the item again is quite a bit simpler. We just first animate all of those items back with a transform so that they are covering the content again, and once they are covering the content we remove the content (basically the same process, just in reverse).

Before We Get Started

We will be building the components or this tutorial inside of an Ionic/StencilJS application. If you are using a framework like Angular or React with Ionic, most of the concepts should reasonably easily port over (especially since a lot of the logic is built on the Ionic Animations API). If there is enough interest, I may create additional versions of this tutorial for other frameworks.

This is an intermediate/advanced tutorial, and I will be skipping over explaining some of the more basic stuff. If you are interested in an in-depth explanation of how the Ionic Animations API works and how to use it to create your own custom animations and interactions, you might be interested in checking out: Advanced Animations & Interactions with Ionic.

The end result of this tutorial will comprise of two custom components that will allow us to easily create an accordion list like this:

<my-accordion-group>
  <my-accordion-item></my-accordion-item>
  <my-accordion-item></my-accordion-item>
  <my-accordion-item></my-accordion-item>
</my-accordion-group>

The Accordion Item Component

We will create the <my-accordion-item> component first since it is a bit simpler, but both of the components will be required for this to work. Most of the logic and animations for opening an item happen by moving other items around, so most of that happens in the <my-accordion-group> component which has access to all the other items, rather than the individual <my-accordion-item> components.

Let's first take a look at the basic structure of the component, and then we will implement the toggleOpen method in detail which contains the most important logic for this component.

import { Component, Listen, State, Element, Host, h, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'my-accordion-item',
  styleUrl: 'my-accordion-item.css',
  shadow: true,
})
export class MyAccordionItem {
  @Element() hostElement: HTMLElement;
  @Event() toggle: EventEmitter;
  @State() isOpen: boolean = false;

  public content: HTMLDivElement;
  private isTransitioning: boolean = false;

  componentDidLoad() {
    this.content = this.hostElement.shadowRoot.querySelector('.content');
  }

  @Listen('click')
  toggleOpen() {

  }

  render() {
    return (
      <Host>
        <div class="header">
          <ion-icon name={this.isOpen ? 'chevron-down' : 'chevron-forward'}></ion-icon>
          <slot name="header"></slot>
        </div>
        <div class="content">
          <slot name="content"></slot>
        </div>
      </Host>
    );
  }
}

Our template mostly consists of a header area and a content area, and each of these have named slots so that we can insert content into those areas when we are using the component. This component will also emit a toggle event which will pass important information back up to the group component, and we are keeping track of a couple of things here like if the item is currently open and if it is currently transitioning between open/closed states.

Now let's take a look at the toggleOpen code:

  @Listen('click')
  toggleOpen() {
    if (this.isTransitioning) {
      return;
    }

    this.isOpen = !this.isOpen;
    this.isTransitioning = true;

    this.toggle.emit({
      element: this.hostElement,
      content: this.content,
      shouldOpen: this.isOpen,
      startTransition: () => {
        this.isTransitioning = true;
      },
      endTransition: () => {
        this.isTransitioning = false;
      },
      setClosed: () => {
        this.isOpen = false;
      },
    });
  }

This method will be triggered any time a click event is detected on the component. However, we want to make sure we only trigger the toggle if it is in a stable open/closed state so we first check the isTransitioning value. The most interesting part here is that we trigger an event (that the group component will listen for) and we pass some information and methods back up to that component. This will tell the group component whether this item is being opened or closed, but it also provides additional information that the component will need. The three methods we supply in this event will allow the group component to easily communicate back to this component to appropriately set the isTransitioning and isOpen values. The element reference will be used to find this individual item in the larger accordion list, and the content element is used so that the group component will be able to determine the correct height for the content that is being displayed.

There is also some CSS we need to add. Most of this is just to get the styling for the individual item components right, but there are a couple of important things here:

:host {
  display: block;
  height: 100%;
  background-color: #fff;
  overflow: auto;
  border: 3px solid #fff;
  will-change: transform;
}

ion-icon {
  font-size: 20px;
  float: right;
  position: relative;
  top: 20px;
}

.header {
  background-color: #f5f5f5;
  padding: 0px 20px 5px 20px;
  border: 1px solid #ececec;
}

.content {
  display: none;
  overflow: auto;
  padding: 0 20px;
}

The items in the accordion list will frequently be transformed as various items are opened/closed so we set the will-change: transform property to reduce some unnecessary paints (if you are not familiar with will-change I would advise not using it in other situations until you have learned more about it). It is also important for us to initially set the content of all of our items to display: none since the content will only be displayed when the item is being opened. Using display: none is important as opposed to say opacity: 0 because we don't want it taking up space in the DOM when it is not visible.

The Accordion Group Component

Now let's take a look at the implementation of the <my-accordion-group> component. We will take a similar approach here, we will first set up the basic outline and then implement the more complex methods in detail.

import { Component, Listen, Element, h } from '@stencil/core';
import { createAnimation, Animation } from '@ionic/core';

@Component({
  tag: 'my-accordion-group',
  styleUrl: 'my-accordion-group.css',
  shadow: true,
})
export class MyAccordionGroup {
  @Element() hostElement: HTMLElement;

  public elementsToShift: Array<any>;
  public blocker: HTMLElement;
  public currentlyOpen: CustomEvent = null;

  public shiftDownAnimation: Animation;
  public blockerDownAnimation: Animation;

  componentDidLoad() {
    this.blocker = this.hostElement.shadowRoot.querySelector('.blocker');
  }

  @Listen('toggle')
  async handleToggle(ev) {
    ev.detail.shouldOpen ? await this.animateOpen(ev) : await this.animateClose(ev);
    ev.detail.endTransition();
  }

  async closeOpenItem() {
    if (this.currentlyOpen !== null) {
      const itemToClose = this.currentlyOpen.detail;

      itemToClose.startTransition();
      await this.animateClose(this.currentlyOpen);
      itemToClose.endTransition();
      itemToClose.setClosed();
      return true;
    }
  }

  async animateOpen(ev) {

  }

  async animateClose(ev) {

  }

  render() {
    return [<slot></slot>, <div class="blocker"></div>];
  }
}

Our template here consists entirely of a <slot> where all of the <my-accordion-item> components will be injected, and then we have our "blocker" element after that so that it displays at the end of the list. Our handleToggle method will be triggered whenever the toggle event from one of our <my-accordion-item> components is detected. This method will handle calling the correct open/close method, and once the animation has finished playing to open or close the component, it will call the endTransition method provided by the individual item so that it can set its isTransitioning value correctly.

We also have an additional closeOpenItem method here. With the way the component is set up, only one item can be open at a time. If there is already an item open when animateOpen is called, it will first close that open item with closeOpenItem. Usually the <my-accordion-item> handles setting its own isOpen and isTransitioning values when it is first clicked, but since this close is triggered from outside of the item the group component will need to call the provided startTransition and setClosed methods to set those values manually.

Now let's take a look at the animateOpen method:

  async animateOpen(ev) {
    // Close any open item first
    await this.closeOpenItem();
    this.currentlyOpen = ev;

    // Create an array of all accordion items
    const items = Array.from(this.hostElement.children);

    // Find the item being opened, and create a new array with only the elements beneath the element being opened
    let splitOnIndex = 0;

    items.forEach((item, index) => {
      if (item === ev.detail.element) {
        splitOnIndex = index;
      }
    });

    this.elementsToShift = [...items].splice(splitOnIndex + 1, items.length - (splitOnIndex + 1));

    // Set item content to be visible
    ev.detail.content.style.display = 'block';

    // Calculate the amount other items need to be shifted
    const amountToShift = ev.detail.content.clientHeight;
    const openAnimationTime = 300;

    // Initially set all items below the one being opened to cover the new content
    // but then animate back to their normal position to reveal the content
    this.shiftDownAnimation = createAnimation()
      .addElement(this.elementsToShift)
      .delay(20)
      .beforeStyles({
        ['transform']: `translateY(-${amountToShift}px)`,
        ['position']: 'relative',
        ['z-index']: '1',
      })
      .afterClearStyles(['position', 'z-index'])
      .to('transform', 'translateY(0)')
      .duration(openAnimationTime)
      .easing('cubic-bezier(0.32,0.72,0,1)');

    // This blocker element is placed after the last item in the accordion list
    // It will change its height to the height of the content being displayed so that
    // the content doesn't leak out the bottom of the list
    this.blockerDownAnimation = createAnimation()
      .addElement(this.blocker)
      .delay(20)
      .beforeStyles({
        ['transform']: `translateY(-${amountToShift}px)`,
        ['height']: `${amountToShift}px`,
      })
      .to('transform', 'translateY(0)')
      .duration(openAnimationTime)
      .easing('cubic-bezier(0.32,0.72,0,1)');

    return await Promise.all([this.shiftDownAnimation.play(), this.blockerDownAnimation.play()]);
  }

We have already discussed the general concept of what is happening in detail, and I have added comments to the code above to highlight the code that is triggering various parts of that process. An important concept being used here is the fact that elements that are positioned with the position CSS property will be stacked above those that are not positioned. Our "blocker" is last in the DOM, meaning naturally it would be above everything else. However, we only want the blocker to be above the item being opened, and under the items beneath the item being opened. To deal with this tricky scenario, we set a position and z-index on all of the items being moved down to force them to be above the blocker element. You might wonder why we can't just use z-index alone, that is because by using a transform the normal rules for element stacking in the DOM are changed, but z-index plus position does the job.

Let's take a look at the animateClose method now:

  async animateClose(ev) {
    this.currentlyOpen = null;
    const amountToShift = ev.detail.content.clientHeight;

    const closeAnimationTime = 300;

    // Now we first animate up the elements beneath the content that was opened to cover it
    // and then we set the content back to display: none and remove the transform completely
    // With the content gone, there will be no noticeable position change when removing the transform
    const shiftUpAnimation: Animation = createAnimation()
      .addElement(this.elementsToShift)
      .afterStyles({
        ['transform']: 'translateY(0)',
      })
      .to('transform', `translateY(-${amountToShift}px)`)
      .afterAddWrite(() => {
        this.shiftDownAnimation.destroy();
        this.blockerDownAnimation.destroy();
      })
      .duration(closeAnimationTime)
      .easing('cubic-bezier(0.32,0.72,0,1)');

    const blockerUpAnimation: Animation = createAnimation()
      .addElement(this.blocker)
      .afterStyles({
        ['transform']: 'translateY(0)',
      })
      .to('transform', `translateY(-${amountToShift}px)`)
      .duration(closeAnimationTime)
      .easing('cubic-bezier(0.32,0.72,0,1)');

    await Promise.all([shiftUpAnimation.play(), blockerUpAnimation.play()]);

    // Hide the content again
    ev.detail.content.style.display = 'none';

    // Destroy the animations to reset the CSS values that they applied. This will remove the transforms instantly.
    shiftUpAnimation.destroy();
    blockerUpAnimation.destroy();

    return true;
  }

Again, I have added comments to the code above to describe what is happening at each step. Perhaps one less obvious thing that is happening here is that to reset everything back to its initial value we call the destroy method on the animations. When the display is set back to none again, the transforms are no longer required because the content isn't taking up space in the DOM, so we can remove the transforms we applied to everything and they will remain in the same position (in fact, if we left the transforms on the items would be incorrectly placed above where they should be).

Finally, we just need a bit of CSS for this component:

:host {
  display: block;
}

.blocker {
  background-color: #fff;
  height: 50px;
  will-change: transform;
}

It's important that you set the background-color of the blocker to whatever the background colour of your page is... otherwise you will have a very noticeable weird box at the bottom of your accordion list. We also use will-change on the blocker since it is constantly moving around.

Using the Component

Now that we have our components defined, we can quite easily build an accordion list whenever we want. Here is the example I used:

<my-accordion-group>
  <my-accordion-item>
    <h3 slot="header">Overview</h3>
    <div slot="content">
      <p>
        Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an
        unknown printer took a galley of type and scrambled it to make a type specimen book.
      </p>
    </div>
  </my-accordion-item>
  <my-accordion-item>
    <h3 slot="header">Characters</h3>
    <ul style={{ paddingLeft: `10px` }} slot="content">
      <li>Mace Tyrell</li>
      <li>Tyrion Lannister</li>
      <li>Sansa Stark</li>
      <li>Catelyn Stark</li>
      <li>Roose Bolton</li>
      <li>Jon Snow</li>
      <li>Hot Pie</li>
    </ul>
  </my-accordion-item>
  <my-accordion-item>
    <h3 slot="header">Plot</h3>
    <p slot="content">Hello there.</p>
    <p slot="content">Hello there.</p>
    <p slot="content">Hello there.</p>
    <p slot="content">Hello there.</p>
    <p slot="content">Hello there.</p>
    <p slot="content">Hello there.</p>
  </my-accordion-item>
  <my-accordion-item>
    <h3 slot="header">Production</h3>
    <div slot="content">
      <p>
        Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an
        unknown printer took a galley of type and scrambled it to make a type specimen book.
      </p>
    </div>
  </my-accordion-item>
  <my-accordion-item>
    <h3 slot="header">Awards</h3>
    <div slot="content">
      <p>
        Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an
        unknown printer took a galley of type and scrambled it to make a type specimen book.
      </p>
    </div>
  </my-accordion-item>
</my-accordion-group>

I created this example because it uses different ways to utilise the slots and has different types/lengths of content.

Summary

This might seem like a lot of work just to avoid animating the height property, but now that we have the component built we can easily use it without having to think about it. There still might be room for improvement in this component because it is just something I put together in a few hours, but in my testing, it was able to easily maintain ~60fps even when testing with 6x CPU Slowdown performance throttling. The key to performance here is that it is doing all of the hard work upfront in the first few milliseconds after it is triggered (which won't be noticeable to the user) and then the animation can play smoothly throughout since it doesn't need to do complex work during the animation (as it would if we were animating height).

If you enjoyed this article, feel free to share it with others!