Development Blog 28th July 2020

Tailwind scroll animations using an Intersection Observer

This is an old article that I have kept for example purposes. These days there are easier ways to achieve similar results. Check out the alpinejs intersect plugin.

I recently rebuilt my Laravel website using TailwindCSS, AlpineJS and Livewire. A magical combination that produces ultra-optimised websites and still allows for all the best features of modern websites.

With optimisation in mind, I had the idea to remove the AOS.js scroll animations package from my website and replace it with custom JavaScript that would instead utilise Tailwind transition and transformation classes, thus shrinking my website assets even further. I managed to figure this out quite quickly and I reduced my JS / CSS bundle by an additional 30 - 40kb. It's not going to gain me much in terms of Page Speed but every KB counts, and I find it good practice to minimise the number of packages a website is dependent on and the different syntaxes that I have to remember.

The Concept

The idea was simple - Use custom data attributes on the elements that I wanted to animate on scroll and then use JavaScript to detect when these elements enter / leave the screen, replacing Tailwind classes appropriately at each event.

On my first attempt, I tried using various NPM packages that detect when elements are on screen, in-view being the first that I tried. It worked fine but I noticed that a lot of these packages were no longer being supported and it had been years since the code has been updated. It turns out that a recent web standard called Intersection Observers have made this process much easier and provide far better performance, hence why the packages I mentioned are no longer maintained.

The Solution

I started with the markup that I wanted - Standard tailwind classes and data attributes to indicate which classes should be swapped in and out as the element enters and leaves the viewport. I called these attributes data-class-in and data-class-out:

html
<div class="transition transform duration-500 opacity-0 translate-x-32" data-class-in="translate-x-0 opacity-100" data-class-out="translate-x-32 opacity-0">
  <p class="font-semibold bg-blue-400 p-5 mb-10 text-white">Tailwind is the best for optimisation!</p>
</div>

I came up with the JavaScript class below which would utilise an Intersection Observer and apply above data attributes. I called this class Animasection.

javascript
// With a lot of help from:
// https://webdesign.tutsplus.com/tutorials/how-to-intersection-observer--cms-30250
class Animasection {

    constructor(options = {}) {  

        // A nice way to initialise default options
        this.options = Object.assign(this, {
            root: null, // relative to document viewport 
            rootMargin: '0px', // margin around root. Values are similar to css property. Unitless values not allowed
            threshold: 0.5 // visible amount of item shown in relation to root (higher values can cause problems)
        }, options);
	
        // This is the guy who will tell us when an element intersects with the viewport
        this.observer = new IntersectionObserver(this.onChange, this.options);
    }
	
    /**
     * Observe all elements that contain either data-class-in or data-class-out
     */
    observeAll() {
         let images = document.querySelectorAll('[data-class-in], [data-class-out]');
         images.forEach(img => this.observer.observe(img));
    }
	
    /**
     * Unobserve all elements that contain either data-class-in or data-class-out
     * Useful for optimisation / page transitions with swup / turbolinks
     */
    unobserveAll() {
        let images = document.querySelectorAll('[data-class-in], [data-class-out]');
        images.forEach(img => this.observer.unobserve(img));
    }
	
    /**
     * The intersection observer will fire this function
     * when at least one of our elements intersects / leaves the viewport
     */
    onChange(changes, observer) {
        changes.forEach(change => {
            if(change.isIntersecting) {
                /* 
                 * 'split' turns our data attribute string into an array, and the spread operator (three dots)
                 * deconstructs it into the format that we need for manipulating the element's 'classList'
                 */
                change.target.classList.remove(...change.target.getAttribute('data-class-out').split(' '))
                change.target.classList.add(...change.target.getAttribute('data-class-in').split(' '))
            }
            else {
                change.target.classList.remove(...change.target.getAttribute('data-class-in').split(' '))
                change.target.classList.add(...change.target.getAttribute('data-class-out').split(' '))
            }
        })
    }
}

export default new Animasection()

If you save this code to a JS file you can then import it and initiate it like so:

javascript
import Animasection from './animasection.js';
Animasection.observeAll()

// You might need to stop observing in some applications
// Animasection.unobserveAll()

Feel free to copy and tweak this code for your own projects. You don't necessarily need to use Tailwind classes but you will need to support ES6. Also, this code won't work on IE11 without a Intersection Observer polyfill, and probably some other polyfills. #RIPIE11.