Messing with the Web Animations API

The Web Animations API is still a very "experimental" technology that I've been trying to find an excuse to play with for some time. It's not something I can really use in my day job because of browser compatibility and general project requirements.

However, during the last couple of days I broke ground on a new side project and an opportunity to get creative arose. The idea behind the project is to visualize some running 👟 statistics of mine that I'm collecting from the Strava API. What can I say? I'm a data junkie.

One of the app's main functions is to filter these statistics by a certain time frame, currently week, month, and year. I tossed around a few ideas as to how this filter component would look and function. The process started in Sketch and eventually moved over to Codepen.

I decided that I'd like to have a filter switch that would allow the user to move between different filter states. This is where the Web Animation API (WAAPI) idea started to come through.

Here's the end result. Open it up in a new tab and poke around. There isn't all that much too it, ~40 lines of JavaScript.

I didn't dive too far into the WAAPI, but what I learned definitely warrants a small post about it. So let's jump in!

Browser compatibility

Before we begin, it's important to note that the WAAPI is still very new. So much so that most browsers don't even support it. As of writing this, CanIUse shows that Firefox v52+ and Chrome v49+ have partial support, but all other major browsers such as IE, Edge, and Safari lack any support at all 😢.

There are some solutions to this issue. GitHub repos such as web-animations-js exist to provide polyfills that bring WAAPI features to browsers that don't support them natively.

HTML structure

If you view the Codepen embed, or "pen" from this point on, you will see that the HTML making up the filter is very simple. I'm a fan of pug templates when working in Codepen but I will outline the structure in pure HTML just for reference:

<div class="filter">
  <div class="filter__item filter__item--active">week</div>
  <div class="filter__item">month</div>
  <div class="filter__item">year</div>
  <div class="filter__switch"></div>
</div>

The .filter container holds the structure of the component. The 3 .filter__item elements represent each time frame. And finally the .filter__switch element will be an absolutely positioned "switch".

CSS

I love flexbox. It's the most used CSS property in my toolbag. The .filter element is a flex container holding three .filter__item elements that are flex children, each one taking up the same width as the others. Let's take a look at the styles that make this work:

.filter {
  /* define structure */

  width: 500px;
  height: 200px;
  border-radius: 100px;
  background: #373b56;

  /* allows for absolute positioning of 
     the .filter__switch element */

  position: relative;

  /* flex styles to align .filter__item elements */

  display: flex;
  justify-content: space-between;
  align-items: center;
}

.filter__item {
  width: 33.33%;
  z-index: 2;
}

.filter__switch {
  /* define structure */

  height: calc(100% - 10px);
  width: calc(33.33% - 10px);
  border-radius: 100px;
  background: #5a6be8;

  /* absolute positioning provides
     the ability to place the switch wherever
     we want it */

  position: absolute;
  top: 5px;
  left: 5px;
}

.filter

The .filter element defines the entire structure using hardcoded values for its dimensions. All children elements will use percentage values for width based on the explicit width set here. It is also a flex container, spreading its .filter__item children evenly within.

.filter__item

The .filter__item element receives a percentage width of one third that of its parent's. When it is active, the z-index property allows it to appear over the .filter__switch. Without it, the .filter__switch would be absolutely positioned over the active .filter__item text, and we don't want that.

.filter__switch

The .filter__switch dimensions are calculated based on the dimensions of its parent container, namely .filter. It's important to note that the subtraction of 10px is to provide what I refer to as absolute padding.

Why is this being done? There needs to be some space between .filter__switch and the inner wall of .filter. Normally this would be solved using some padding. The problem is that padding has no affect on absolutely positioned elements.

In order to solve this we can set .filter__switch to be 100% of its parent's height and 33.33% of its width (the same as .filter__item), and then subtract 10px. This effectively leaves 10px of empty space that can be used as absolute padding around the switch.

The final piece to the puzzle is the directional properties top and left. By specifying top: 5px and left: 5px we are pushing .filter__switch into the middle of .filter, giving us 5px of that sweet absolute padding.

If you're wondering how we got 5px, just think about it like this. We have 10px of empty space. To center the switch we put 5px on top and 5px on the left. 5 + 5 = 10. Maths.

JS: The meat and potatoes

We now have a structurally and stylistically complete filter, but we're still missing the functionality. This is the most important part of the component and this post. Let's get right to it.

The goal

Identify the position of the clicked .filter__item and move the .filter__switch directly behind it. Not too difficult, right? In fact, it's really not. The Document Object Model exposes a handy method for us to determine the coordinates and dimensions of any element. We'll talk about it shortly.

The implementation

Before we jump into the JavaScript I want to stop real quick and break away from the terminology used in the CSS section. I have been referring to three elements: .filter, .filter__item, and .filter__switch by their class names. In this section we'll be stepping through some JavaScript and there will be variable names that may clash with the class names used in the CSS. From this point on I'll refer to:

  • .filter as the filter
  • .filter__item as filter item
  • .filter__switch as switch

Moving on.

We need a click event on each filter item to let us know where to move the switch. The best way to handle this is to add the event listener to the filter and only perform an action if a filter item triggered the event. This is called event delegation.

document.querySelector('.filter').addEventListener('click', (e) => {
  e.stopPropagation();

  if (
    e.target.classList.contains('filter__item') &&
    !e.target.classList.contains('filter__item--active')
  ) {
    // do something...
  }
});

Event delegation usually requires the stopPropagation() method of the event to prevent it from bubbling up the DOM. When the event is fired we can check if the element that triggered it was a filter item but not one that was already active.

The next step is to identify the position of the filter item that was clicked. This is possible using a method named getBoundingClientRect(). This method can be called on any element in the DOM and will return some dimensions and positional data about it.

document.querySelector('.filter').addEventListener('click', (e) => {
  e.stopPropagation();

  const el = e.target;

  if (
    el.classList.contains('filter__item') &&
    !el.classList.contains('filter__item--active')
  ) {
    const filterItemData = el.getBoundingClientRect();
    const switchData = document
      .querySelector('.filter__switch')
      .getBoundingClientRect();
  }
});

The filterItemData variable contains information regarding the clicked filter item's whereabouts in the DOM. We'll also do the same for the switch, storing its data in the aptly named variable switchData.

The maths

At the heart of this animation is a mathematical formula that calculates the new x coordinate for the switch. The goal is to move the switch directly behind the filter item so that the text of the filter item sits in the middle of the switch. If we can calculate the x coordinate that lies directly in the middle of the filter item, then we can use it to determine where to move the switch such that the two elements will line up perfectly.

Finding the mid point

The filterItemData variable includes an x coordinate and a width property. The x coordinate signifies where the element starts on the x axis. If we cut the width in half and add it to x we'll get a new value representing the mid point.

Notice that I'm not calling it the mid point of the filter item. Technically it is, but it's also the new mid point of the switch. If they are going to sit perfectly on top of each other in the DOM then they must share mid points.

Finding the new x coordinate of the switch

We've found the mid point but we still need to find the new x coordinate of the switch. How can this be done?

Think about how we found the mid point.

We knew the x coordinate and the width of the filter item. By adding half of the width to x we calculated the mid point. This time the situation is reversed. We have the mid point and the switch's width. All that needs to be done is to subtract half of the switch's width from the mid point!

document.querySelector('.filter').addEventListener('click', (e) => {
  e.stopPropagation();

  const el = e.target;

  if (
    el.classList.contains('filter__item') &&
    !el.classList.contains('filter__item--active')
  ) {
    const filterItemData = el.getBoundingClientRect();
    const switchData = document
      .querySelector('.filter__switch')
      .getBoundingClientRect();

    const midPoint = filterItemData.x + filterItemData.width / 2;
    const newSwitchX = midPoint - switchData.width / 2;
  }
});

Not too much math and pretty straight forward. Take a few minutes to look over the current code before moving on.

Animating the switch

Now that the new x coordinate for the switch has been calculated, all that's left to do is move the sucker. This is where the WAAPI comes in to play. If you've ever used CSS animations and transitions then this next step will feel pretty familiar to you. The WAAPI allows us to tap in to the browser animation engine using JavaScript.

You may say to yourself, "Can't I just keep doing this in CSS?". Sure you can, but moving animations from CSS to JavaScript has a number of benefits. It keeps behavior with behavior and style with style. It allows us to dynamically update animations using calculated values like the above. And it's a more performant way to animate in the browser.

In CSS animations there is this concept of @keyframes, or sequential steps defined by the developer that control the animation of an element on the page. The WAAPI allows for defining keyframes in JavaScript using an array of keyframe objects.

const keyframes = [
  { keyframeOne }
  { keyframeTwo }
  { keyframeThree }
]

Let's update the event listener with an array of keyframes that defines the switch's animation from its current position to its new position.. We'll do this using CSS transforms:

document.querySelector('.filter').addEventListener('click', (e) => {
  e.stopPropagation();

  const el = e.target;

  if (
    el.classList.contains('filter__item') &&
    !el.classList.contains('filter__item--active')
  ) {
    const filterItemData = el.getBoundingClientRect();
    const switchData = document
      .querySelector('.filter__switch')
      .getBoundingClientRect();

    const midPoint = filterItemData.x + filterItemData.width / 2;
    const newSwitchX = midPoint - switchData.width / 2;

    const keyframes = [
      { transform: `translateX(${switchData.x}px)` },
      { transform: `translateX(${newSwitchX}px)` }
    ];
  }
});

There is one hiccup to the keyframes defined above? Can you spot it? I'll give you a hint: It has to do with the nature of the CSS translateX function.

The problem is that the translateX function moves the element x pixels away from the origin of the element on the screen. It doesn't move the element to the exact value specified.

If we calculated that the future x coordinate of the switch is 452, and we pass that value in a keyframe object as translateX(452px), the switch would move 452px forward from its current position on the x axis. That's clearly not what we want.

Instead, we should be passing the difference in pixels between the switch's origin and its current and future x coordinates. Make sense? In order to accomplish this we'll need to define the origin of the switch and keep a reference to that throughout the life of the filter.

Defining the switch origin

At first glance this seems like a pretty simple task. Let's just reach for that handy getBoundingClientRect() method and call it on the switch, grab its initial x coordinate and call it a day, right? Well not exactly. There are two caveats to defining the origin.

Number one being that the origin may change if the user adjusts the browser window size. Without taking this into account our math would be using an out of date origin that could potentially result in our switch flying off the screen. This means that we'll have to recalculate the origin every time the event listener runs.

The second issue is that recalculating the origin of the switch every time the event listener runs will undoubtedly break our animation because we're calculating a value that's constantly changing! The origin will never stay static because the switch is always moving.

However, there is an elegant solution to these two problems. Instead of calculating the origin of the switch, we can calculate the origin of the first filter item. This is perfect because the filter items themselves don't move. Just the switch. By calculating the origin of the first filter item we're essentially calculating the origin of the switch because it begins in the same place. On subsequent triggers to the event we'll always have the correct value, no matter where the switch lies in the DOM.

Let's update the code to reflect this:

document.querySelector('.filter').addEventListener('click', (e) => {
  e.stopPropagation();

  const el = e.target;

  if (
    el.classList.contains('filter__item') &&
    !el.classList.contains('filter__item--active')
  ) {
    const filterItemData = el.getBoundingClientRect();
    const switchData = document
      .querySelector('.filter__switch')
      .getBoundingClientRect();
    const origin =
      document.querySelector('.filter__item').getBoundingClientRect().x + 5;

    const midPoint = filterItemData.x + filterItemData.width / 2;
    const newSwitchX = midPoint - switchData.width / 2;

    const keyframes = [
      { transform: `translateX(${switchData.x - origin}px)` },
      { transform: `translateX(${newSwitchX - origin}px)` }
    ];
  }
});

Note that by add 5 to the x coordinate of the first filter item we're defining the correct point of origin for the switch. The switch was absolutely positioned 5px to the left in the CSS section and we're compensating for that here.

The keyframe objects have also been updated to receive the difference of the current and future x coordinates and the origin, giving us the correct pixel values needed to animate the switch.

Excellent, now that the leg work has been taken care of, the last part is to define some timing options and animate the switch!

The WAAPI requires that we define some timing properties to be used in conjunction with the keyframe objects. Things like duration and easing. Another important property we'll define is the fill property, which is similar to the CSS animation-fill-mode property. It specifies how the element should be styled before and after the animation.

We want the switch to retain its position once it's been moved, therefore we'll pass a value of forwards to the fill property, which tells the element to stay where it is after its done animating.

const filterSwitch = document.querySelector('.filter__switch');

document.querySelector('.filter').addEventListener('click', (e) => {
  e.stopPropagation();

  const el = e.target;

  if (
    el.classList.contains('filter__item') &&
    !el.classList.contains('filter__item--active')
  ) {
    const filterItemData = el.getBoundingClientRect();
    const switchData = filterSwitch.getBoundingClientRect();
    const origin =
      document.querySelector('.filter__item').getBoundingClientRect().x + 5;

    const midPoint = filterItemData.x + filterItemData.width / 2;
    const newSwitchX = midPoint - switchData.width / 2;

    const keyframes = [
      { transform: `translateX(${switchData.x - origin}px)` },
      { transform: `translateX(${newSwitchX - origin}px)` }
    ];

    const options = {
      duration: 300,
      easing: 'cubic-bezier(0.42, 0, 0.58, 1)',
      fill: 'forwards'
    };

    filterSwitch.animate(keyframes, options);

    document
      .querySelector('.filter__item--active')
      .classList.remove('filter__item--active');
    el.classList.add('filter__item--active');
  }
});

There it is. We've detected a click event on a filter item, determined the new position of the switch relative to the clicked item, and animated it accordingly. The switch is also responsive to dynamic changes in browser window width.

It was a fun component to work on and adds some nice UI/UX touches to an otherwise boring part of a larger app. You can check out the final version here, fork and play around with it. If you have any questions regarding the Web Animations API or the code above please feel free to reach out on Twitter. Thanks for reading!

Jake Wiesler

Hey! 👋 I'm Jake

Thanks for reading! I write about software and building on the Web. Learn more about me here.

Subscribe To Original Copy

A weekly email for makers on the Web.

Learn More