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.
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); }); }); }
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 }); });
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.
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); });
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.
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:
animationstart
event is fired whenever a CSS animation starts playingHence, 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 } }
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]); } }
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.
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 } }
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; } } }
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.
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:
.force-only
or .force-first
class should be considered for the corresponding child position.force-last
class should be considered to always be the last childBased 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...
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 } }
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 = ''; } } }
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 } }
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); } }
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:
HAPPY CODING!!!
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.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. 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 and mobile apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]