Animations with the Intersection Observer API – 2024

This is a follow-up to the Animations with the Intersection Observer API post from July 27th, 2022. This is a new, completely re-written version of the Javascript functionality that I have been using on recent client websites.

Using the intersection observer we can have the browser watch for when elements that contain a specific class enter the viewport. Using CSS and applying classes at the right moment, we can bring the elements from a state of being hidden to a state of appearing on the page. This gives the effect of a transition animation that brings the elements into view as the user scrolls through the page.

This script supports two effects; fade-in and slide-in. The ‘fade-in’ effect uses the opacity CSS property. The ‘slide-in’ effect uses the transform CSS property as well as the opacity CSS property. The ‘slide-in’ property can transition either horizontally or vertically and it can transition in to specific coordinates other than just (0, 0). More effects may be added soon.

Using data attributes set on each individual element we can specify some additional configurations to give you more control over the behavior of the animation. The data-threshold attribute lets you specify how much of the element should be visible to the viewport (while still being invisible to the user of course) before the animation triggers. The data-delay attribute lets you specify how much time to wait after the data-threshold is met but before the animation triggers. Exclusive to the ‘slide-in’ effect is the data-axis attribute, allowing us to specify which translate axis to use, either ‘X’ or ‘Y’. Also exclusive to the ‘slide-in’ effect is the data-distance attribute which allows us to specify (using the ‘px’ unit) how many pixels in any directions (top, right, bottom, left) the element should begin at before animating in to place.

This script also accounts for whether or not the user has scrolled down throughout the page and then refreshed. In this case, if the animation is “above the viewport” then the animations will already be in their triggered positions. This prevents the animations from running as the user scrolls up which would look strange in most cases. This of course can be disabled.

HTML

I don’t normally write my HTML elements on multiple lines but I think it helps for legibility in this case:

Fade-In effect:

<div 
    class="fade-in" 
    data-delay="2" 
    data-threshold="0.25"
>
    <p>Your content</p>
</div>

Fade-In effect for use when the element is likely to be displayed “above the fold”. This prevents the element from flickering during page load:

<div 
    class="fade-in" 
    data-delay="2" 
    data-threshold="0.25"
    data-above-the-fold="true"
    style="opacity:0;"
>
    <p>Your content</p>
</div>

Slide-In effect:

<div 
    class="slide-in" 
    data-delay="1" 
    data-threshold="0.1" 
    data-distance="-500px" 
    data-axis="x"
>
   <p>Your content</p>
</div>

Slide-In effect for use when you want the animation to end at coordinates other than (0, 0).

<div 
    class="slide-in" 
    data-delay="1" 
    data-threshold="0.1" 
    data-distance="-500px" 
    data-axis="x" 
    data-custom-trigger-class="your-class"
>
   <p>Your content</p>
</div>
.js-enabled {
    .slide-in[data-axis="x"],
    .slide-in[data-axis="y"] {
        &.slide-in--triggered__your-class {
            transform: translateX(-50px) translateY(0);
        }
    }
}

CSS

// ***********************************
//  Animations
// ***********************************

// **************************
//  Default to visible in case JavaScript is disabled
// **************************
.fade-in {
    opacity: 1;
}

.slide-in {
    opacity: 1;
    transform: translateX(0) translateY(0);
}

// **************************
//  Effects
// **************************
.js-enabled {
    // fade-in transition
    .fade-in {
        opacity: 0;
        transition: opacity var(--t-slow);
    }

    .fade-in--triggered {
        opacity: 1;
    }

    // slide-in transition
    .slide-in {
        opacity: 0;
        transition: opacity var(--t-slow), transform var(--t-slow);
    }

    .slide-in[data-axis="x"] {
        transform: translateX(var(--slide-in));
    }

    .slide-in[data-axis="y"] {
        transform: translateY(var(--slide-in));
    }

    .slide-in[data-axis="x"],
    .slide-in[data-axis="y"] {
        &.slide-in--triggered {
            opacity: 1;
            transform: translateX(0) translateY(0);
        }

        &.slide-in--triggered__your-class {
            transform: translateX(0) translateY(-50px);
        }
    }
}

JavaScript

function animations() {
    // ***********************************
    //  Intersection Observer for transitions and animations
    //  by Alex Winter - 2024-04-19 - v1.0
    //  updated by AW  - 2024-06-06 - v1.0.1 - Added support for 'Y' axis 'slide-in' transitions
    //  
    //  Requires 'transitions.scss'
    //  Make sure it is enabled in app.scss
    //
    //  Todo:
    //  - Add data attribute to set the transition duration
    // ***********************************

    // example usage:
    // <div class="fade-in" data-delay="2" data-threshold="0.25">
    //    <p>Your content</p>
    // </div>

    // example usage:
    // <div class="fade-in" data-delay="0.5" data-threshold="1" data-above-the-fold="true" style="opacity:0;">
    //    <p>Your content ('data-above-the-fold' and 'style' attributes to avoid flickering)</p>
    // </div>

    // example usage:
    // data-custom-trigger-class is optional for specifying custom X, Y coordinates for your transform property
    // <div class="slide-in" data-delay="1" data-threshold="0.1" data-distance="-500px" data-axis="x" data-custom-trigger-class="welcome-image">
    //    <p>Your content</p>
    // </div>

    document.querySelector('body').classList.add('js-enabled');

    // **************************
    //  Effects
    // **************************
    // fade in
    const fadeIn = (element, delay, isAboveTheFold) => {
        setTimeout(() => {
            element.classList.add('fade-in--triggered');

            if (isAboveTheFold) {
                element.style.opacity = 1;
            }
        }, delay ? delay * 1000 : 0);
    };

    // slide in
    const slideIn = (element, delay, isAboveTheFold, distance, customTriggerClass) => {
        element.style.setProperty('--slide-in', distance);

        setTimeout(() => {
            if (customTriggerClass) {
                element.classList.add('slide-in--triggered__' + customTriggerClass);
            } else {
                element.classList.add('slide-in--triggered');
            }

            if (isAboveTheFold) {
                element.style.transform = 'translate(0, 0)';
            }
        }, delay ? delay * 1000 : 0);
    };

    // **************************
    // If the element is above the viewport (user refreshes page after scrolling down) then trigger the effect immediately
    // **************************
    const isAboveViewport = (element, aboveTheFold) => {
        const rect = element.getBoundingClientRect();
        // console.log('rect.bottom value:\n' + rect.bottom + '\n' + element.classList); // debug

        if (rect.bottom < 0) {
            if (element.classList.contains('fade-in')) {
                fadeIn(element, 0, aboveTheFold);
            }
            else if (element.classList.contains('slide-in')) {
                slideIn(element, 0, aboveTheFold, null);
            }
            // console.log('ABOVE VIEWPORT:\n' + element.classList); // debug
        }
    };

    // **************************
    // Intersection Observer which triggers the effect when the element intersects with the viewport
    // **************************
    const createObserver = (element, threshold) => {
        const observer = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const delayValue = entry.target.dataset.delay; // Get the delay value from the data-delay attribute
                    const aboveTheFold = entry.target.dataset.aboveTheFold; // true or false
                    const distance = entry.target.dataset.distance || null; // Default null if not specified
                    const customTriggerClass = entry.target.dataset.customTriggerClass || null; // Default null if not specified

                    if (entry.target.classList.contains('fade-in')) {
                        fadeIn(entry.target, delayValue, aboveTheFold);
                    } else if (entry.target.classList.contains('slide-in')) {
                        slideIn(entry.target, delayValue, aboveTheFold, distance, customTriggerClass);
                    }

                    observer.unobserve(entry.target);

                    // console.log('IS INTERSECTING:\n' + entry.target.classList); // debug
                } else {
                    // console.log('NOT INTERSECTING:\n' + entry.target.classList); // debug
                }
            });
        }, {
            // Documentation:
            // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
            root: null,
            rootMargin: '0px',
            threshold: parseFloat(threshold) // Parse the threshold value
        });

        observer.observe(element);
    };

    // **************************
    // Get all the elements using the effects class names
    // **************************
    const getElements = (elementClass) => {
        window.addEventListener('load', () => {
            setTimeout(() => {
                const targets = document.querySelectorAll('.' + elementClass);

                targets.forEach(targetElement => {
                    const thresholdValue = targetElement.dataset.threshold || 0.25; // Default threshold if not specified
                    const aboveTheFold = targetElement.dataset.aboveTheFold || false; // Default false if not specified

                    createObserver(targetElement, thresholdValue, aboveTheFold);

                    isAboveViewport(targetElement, aboveTheFold);
                });
            }, 100); // Small timeout to ensure the layout has stabilized
        });
    };

    // run
    getElements('fade-in');
    getElements('slide-in');
};
animations();