DEV Community

Cover image for CSS animations for DOM observation
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

CSS animations for DOM observation

Written by Glad China✏️

Web developers are often tasked with building applications that can adapt to a plethora of changes, usage conditions, and user actions.

Simply put, the appearance and behavior of a web application at any given time is a function of many variables: application state, device viewport, device capabilities, network conditions, and user preferences, to mention a few. In order to keep track of these variables and react to changes, some form of observation and detection will be required.

Observation has been an integral part of web development for a long time. You might already know Web APIs like IntersectionObserver, MutationObserver, PerformanceObserver, etc. However, it is easy to overlook that event listeners are also observers themselves. As a matter of fact, each time you set up an event listener for a particular event on one or more event targets, you’ve just got yourself an observer.

That said, here is a question for us:

What if we are interested in observing and reacting to some form of changes in the DOM like node insertions, node removals, attribute changes, etc.?

Someone might say, “Well, that will not be necessary.” Another might scream, “MutationObserver!”

But what if I said CSS animations? Think about it for a moment.

In this article, we will explore how we can leverage CSS animations for the purpose of observing changes in the DOM, along with the kinds of changes that can be observed, using practical examples.

LogRocket Free Trial Banner

Background

Imagine working on a project where you are required to build a script that developers can add to their web application to embed certain elements like iframes, buttons, widgets, etc. at different parts of the application.

Let’s say, for example, that for every element with the .share-button class, you are required to replace it with a special kind of button for sharing some content on some platform.

You could quickly do something like this:

document.addEventListener('DOMContentLoaded', () => {
  replaceWithShareButton(document.querySelectorAll('.share-button'));
});

function replaceWithShareButton (nodes) {
  Array.from(nodes).forEach(node => {
    const button = document.createElement('button');

    // set button attributes and properties here

    requestAnimationFrame(() => {
      node.parentNode.replaceChild(button, node);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

This is perfect as long as all the .share-button elements already exist as part of the page markup when it is loaded — for example, if the page was server-side rendered. However, when more .share-button elements get added to the page dynamically, nothing happens.

To handle this new scenario, you decide to leverage the MutationObserver API to watch the DOM tree for new .share-button elements being added, and effectively replace them with the special button.

The following code snippet shows some modifications you could make to the DOMContentLoaded event listener from earlier in order to set up the mutation observer.

document.addEventListener('DOMContentLoaded', () => {
  replaceWithShareButton(document.querySelectorAll('.share-button'));

  const observer = new MutationObserver(mutations => {
    for (let mutation of mutations) {
      const nodes = Array.from(mutation.addedNodes)
        .filter(node => node.classList.contains('share-button'));

      replaceWithShareButton(nodes);
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });
});
Enter fullscreen mode Exit fullscreen mode

Thanks to the awesome MutationObserver API, you’ve succeeded in getting the button replacements to work perfectly with very little effort.

A couple days later, the project manager comes to you and says he’s been receiving complaints from developers saying that the button replacements aren’t happening on some browsers, and you are required to quickly fix it.

After doing some research, you soon realize that the issue was because the MutationObserver API is not supported by some browsers that still happen to be in use.

MutationObserver API Browser Support
Browser support for the MutationObserver API, via Can I use…

The question now is: What other options do you have, while still ensuring that you support some more browsers?

Of course, you can try the long polling technique, where you check at intervals for .share-button elements that have just been added and replace them. So, you modify the DOMContentLoaded event listener again to use long polling, like so:

document.addEventListener('DOMContentLoaded', () => {
  replaceWithShareButton(document.querySelectorAll('.share-button'));

  const POLLING_INTERVAL = 2000;

  setTimeout(function replaceNewerNodes () {
    replaceWithShareButton(document.querySelectorAll('.share-button'));
    setTimeout(replaceNewerNodes, POLLING_INTERVAL);
  }, POLLING_INTERVAL);
});
Enter fullscreen mode Exit fullscreen mode

While using this technique works on a wider range of browsers (including the pretty old ones), it is very clear that it will have some performance issues, especially with setTimeout() being called frequently. Also, coupled with the fact that the button replacements will not happen in real time due to the polling interval, you decide not to go with this technique.

So you are back to looking for alternatives.

Don’t you think you should try bringing CSS animation into the mix? Let’s see how that can help solve your little problem.

Basic principle

A couple years ago, David Walsh wrote on his blog about a technique for detecting DOM node insertions with JavaScript and CSS animations, which he said was introduced to him by Daniel Buchner, a Mozilla developer at the time.

The basic principle of this technique is as follows:

  • CSS animation is added to the target elements to be observed
  • CSS animation starts playing when those elements get added to the DOM
  • The animationstart event is fired whenever a CSS animation starts playing

Hence, to solve the problem you encountered earlier, you will have to first set up the following styles on the page:

.share-button {
  animation-name: button-inserted;
  animation-duration: 1ms;
}

@keyframes button-inserted {
  from { opacity: 0.9999999 }
  to   { opacity: 1 }
}
Enter fullscreen mode Exit fullscreen mode

Then you can update the DOMContentLoaded event listener as follows:

document.addEventListener('DOMContentLoaded', () => {
  replaceWithShareButton(document.querySelectorAll('.share-button'));

  document.addEventListener('animationstart', handleButtonInsertion, false);
  document.addEventListener('MSAnimationStart', handleButtonInsertion, false);
  document.addEventListener('webkitAnimationStart', handleButtonInsertion, false);
});

function handleButtonInsertion (evt) {
  if (evt.animationName === 'button-inserted') {
    replaceWithShareButton([evt.target]);
  }
}
Enter fullscreen mode Exit fullscreen mode

With these changes and additions, everything works just like before with the MutationObserver API, but with a little more support for older browsers. However, when compared with the MutationObserver API, using the CSS animation technique is very limited in the extent of DOM modifications it can be used to observe.

First, last, and only child

We’ve already seen how we can detect new node insertions using the CSS animation technique, but what more can we achieve with this?

Let’s say we want to ensure that an element is always the first, last, or only child of its parent, irrespective of whether a new node is added into or removed from the parent.

We can use the CSS animation technique like so:

.force-first:not(:first-child),
.force-last:not(:last-child),
.force-only:not(:only-child) {
  animation-name: reset-child-position;
  animation-duration: 1ms;
}

@keyframes reset-child-position {
  from { opacity: 0.9999999 }
  to   { opacity: 1 }
}
Enter fullscreen mode Exit fullscreen mode

Here, we are using three classes — .force-first, .force-last, and .force-only — to designate the target element to always be the first, last, or only child of its parent, respectively.

Notice the use of the :not() pseudo-class together with the :first-child, :last-child, and :only-child pseudo-classes to trigger a CSS animation that will be used to ensure the target element is positioned correctly within its parent after every node insertion.

The event listener we need to ensure these behaviors is as follows:

document.addEventListener('DOMContentLoaded', () => {
  document.addEventListener('animationstart', resetChildNodePosition, false);
  document.addEventListener('MSAnimationStart', resetChildNodePosition, false);
  document.addEventListener('webkitAnimationStart', resetChildNodePosition, false);
});

function resetChildNodePosition (evt) {
  if (evt.animationName === 'reset-child-position') {
    const elem = evt.target;
    const parent = elem.parentNode;
    const classes = elem.classList;
    const clonedElem = elem.cloneNode(true);

    const lastChild = parent.lastChild;
    const firstChild = parent.firstChild;

    if (classes.contains('force-only')) {
      if (elem !== firstChild || elem !== lastChild) {
        parent.innerHTML = '';
        parent.appendChild(clonedElem);
      }
      return;
    }

    if (classes.contains('force-last')) {
      if (elem !== parent.lastChild) {
        parent.removeChild(elem);
        parent.appendChild(clonedElem);
      }
      return;
    }

    if (classes.contains('force-first')) {
      if (elem !== parent.firstChild) {
        parent.removeChild(elem);
        parent.insertBefore(clonedElem, parent.firstChild);
      }
      return;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A careful observation of the above code snippets will indicate that just one element within the parent is expected to be designated as .force-first, .force-last, or .force-only at any time.

So what happens when we have more than one element within the parent element designated as, say, .force-first? Well, we run into some problems with our code there.

The following gif shows a simple demonstration of this problem, where two elements inside the same parent are both struggling to always be the first child.

Two Elements Struggling For The First Child Position
Two elements struggling for the first child position.

To fix this issue, we have to decide which element wins in the case of multiple elements struggling for the same position. Here are some reasonable decisions we can make:

  • Only the first element within the parent with a .force-only or .force-first class should be considered for the corresponding child position
  • Only the last element within the parent with a .force-last class should be considered to always be the last child

Based on these decisions, here are the modifications we have to make:

// ...code truncated here...

if (classes.contains('force-only')) {
  const target = parent.querySelectorAll('.force-only')[0];

  if (elem === target && !(elem === firstChild && elem === lastChild)) {
    parent.innerHTML = '';
    parent.appendChild(clonedElem);
  }

  return;
}

if (classes.contains('force-last')) {
  const targets = parent.querySelectorAll('.force-last');
  const target = targets[targets.length - 1];

  if (elem === target && elem !== parent.lastChild) {
    parent.removeChild(elem);
    parent.appendChild(clonedElem);
  }

  return;
}

if (classes.contains('force-first')) {
  const target = parent.querySelectorAll('.force-first')[0];

  if (elem === target && elem !== parent.firstChild) {
    parent.removeChild(elem);
    parent.insertBefore(clonedElem, parent.firstChild);
  }

  return;
}

// ...code truncated here...
Enter fullscreen mode Exit fullscreen mode

Without child

In the previous section, we were majorly concerned about controlling the behavior of some child elements within their parent. It is already very interesting to see how this technique could be used in ensuring several DOM elements maintain specific positions within their parent.

In this section, let’s explore other ways we can put this technique to use by controlling the behavior of an element when it is with or without child(ren).

Let’s say we want to ensure that an element never has a child, i.e., we always want the element to be empty. Again, we can apply the CSS animation technique by creating style rules that look like this:

.without-child:not(:empty) {
  animation-name: element-not-empty;
  animation-duration: 1ms;
}

@keyframes element-not-empty {
  from { opacity: 0.9999999 }
  to   { opacity: 1 }
}
Enter fullscreen mode Exit fullscreen mode

Also, we will set up event listeners like so:

document.addEventListener('DOMContentLoaded', () => {
  document.addEventListener('animationstart', removeNodeChildren, false);
  document.addEventListener('MSAnimationStart', removeNodeChildren, false);
  document.addEventListener('webkitAnimationStart', removeNodeChildren, false);
});

function removeNodeChildren (evt) {
  if (evt.animationName === 'element-not-empty') {
    const elem = evt.target;

    if (elem.children.length > 0) {
      elem.innerHTML = '';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Well, that was pretty easy, given what we’ve already done before now. That said, we can still explore other ways of handling empty elements.

Let’s say we want to ensure that an element is never empty — this is kinda the reverse of our previous example. If the element happens to be empty, let’s say we want to simply remove it from the DOM. Here are some style rules we can create for that:

.no-without-child:empty {
  animation-name: element-empty;
  animation-duration: 1ms;
}

@keyframes element-empty {
  from { opacity: 0.9999999 }
  to   { opacity: 1 }
}
Enter fullscreen mode Exit fullscreen mode

The event listeners should be set up as follows:

document.addEventListener('DOMContentLoaded', () => {
  document.addEventListener('animationstart', removeEmptyNode, false);
  document.addEventListener('MSAnimationStart', removeEmptyNode, false);
  document.addEventListener('webkitAnimationStart', removeEmptyNode, false);
});

function removeEmptyNode (evt) {
  if (evt.animationName === 'element-empty') {
    const elem = evt.target;
    elem.parentNode.removeChild(elem);
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we’ve been able to see how CSS animations can be leveraged for observing DOM mutations — particularly node insertions and node emptiness. While this technique can be explored further in areas of observing element attributes, it is generally impractical for that purpose.

It is clearly not as powerful as the MutationObserver API in so many ways, and should only be considered as an alternative if supporting old browsers is of particular concern, and the mutations to be observed are within the scope of what was discussed in this article.

That said, I am building a very simple and lightweight JavaScript library for the purpose of observing and reacting to DOM mutations based on the CSS animations technique discussed in this article. I will be tweeting a lot about it — watch out for it.

I’m glad you made it to the end of this article. It was quite a lengthy one, and I do hope it was worth your while. As always, please remember to:

  • Comment your feedback
  • Share with someone
  • Follow me on Twitter

HAPPY CODING!!!


Is your frontend hogging your users' CPU?

As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.

Alt Text

LogRocket is like a DVR for web apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web apps — Start monitoring for free.


The post CSS animations for DOM observation appeared first on LogRocket Blog.

Top comments (0)