Simple Scroll-triggered Animations with Data Attributes

Sometimes you want scroll animations without tying into a big JavaScript framework or tightly coupling your animations to a specific class or selector. You just want to sprinkle in a fade or slide effect when something scrolls into view, using nothing but data- attributes.

That’s exactly what this lightweight scroll-triggered animation script gives you.

This setup uses the IntersectionObserver API, which is highly performant compared to traditional scroll event listeners. It tracks visibility changes efficiently without constant polling, even on pages with many animated elements. And the script itself is tiny (under 6kb when minified), making it a smart choice for performance-conscious builds.

It’s also page builder and CMS agnostic, whether you’re using Gutenberg, Beaver Builder, Elementor, Oxygen, Etch, or something custom. As long as you can add data- attributes to an element, you’re good to go.

How it works

This script observes elements with the data-scroll-animation-class attribute and watches when their top edge crosses a percentage-based threshold of the viewport. When an element’s top moves above that line, it gets the class you specify.

Here are all the available data attributes and how they work:

AttributeRequiredDescriptionExamples
data-scroll-animation-classYesThe CSS class to apply when the element scrolls into view."fade-in", "slide-up"
data-scroll-animation-thresholdNoControls the trigger point from the top of the viewport. Accepts decimals (0-1), pixel values, or other CSS units. Defaults to 0.75"0.75", "200px", "10rem"
data-scroll-animation-reverseNoUse "true" to remove the class when scrolling back up, or specify a class name to toggle instead."true", "fade-out"
data-scroll-childrenNoAnimates child elements instead of the parent. Use "true" for direct children, or specify a CSS class to target any descendant."true", "card"
data-scroll-children-staggerNoAdds delay between child animations when using data-scroll-children. Accepts seconds or milliseconds."0.1s", "100ms"
data-scroll-debugNoShows visual debug lines to help with development. Remove before deploying to production."true"

This script observes elements with the data-scroll-animation-class attribute and watches when their top edge crosses a percentage-based threshold of the viewport. When an element’s top moves above that line, it gets the class you specify.

Here are all the available data attributes:

data-scroll-animation-class (required)
The CSS class to apply when the element scrolls into view.
Examples: "fade-in", "slide-up"

data-scroll-animation-threshold (optional)
Controls the trigger point as a percentage of viewport height. Accepts decimals (0-1), pixel values, or CSS units. Defaults to 0.75 (75% down viewport).
Examples: "0.75" (75% down), "200px", "10rem"

data-scroll-animation-reverse (optional)
Controls reverse animation behavior. Use "true" to remove the class when scrolling back up, or specify a class name to add instead. Only triggers after element has animated in once.
Examples: "true", "fade-out"

data-scroll-children (optional)
Animates child elements instead of the parent. Use "true" for direct children, or specify a class name to target only descendants with that class.
Examples: "true", "card", "feature-item"

data-scroll-children-stagger (optional)
Adds delay between child animations when using data-scroll-children. Accepts seconds or milliseconds.
Examples: "0.1s", "100ms"

data-scroll-debug (optional)
Shows visual debug lines to help with development. Remove before deploying to production.
Example: "true"


New in v1.2.0: Child animation features (data-scroll-children and data-scroll-children-stagger) are perfect for animating lists, card grids, or any container where you want individual items to animate rather than the parent item/wrapper.

No no extra setup, just add your data- attributes and your CSS animations for those classes.

On to the Code!

Here is the minified JS file:

Download AttrScrollAnimator.min.js

And here is the full code + description if you want to see it:

JavaScript
/**
 * AttrScrollAnimator - Lightweight Scroll Animation via Data Attributes
 * Adds class to elements when they scroll into view using a viewport trigger line.
 * Behavior is configured entirely through data-attributes in your HTML.
 *
 * Author: Zack Pyle
 * URL: https://snippetnest.com/snippet/simple-scroll-triggered-animations-with-data-attributes/
 * Version: 1.2.0
 *
 * === Available Data Attributes ===
 * 
 * data-scroll-animation-class="fade-in"
 *   - Required. The class to apply when the element scrolls into view.
 *
 * data-scroll-animation-threshold="0.75"
 *   - Optional. How far down the viewport the element must reach before triggering.
 *     Can be a decimal (e.g. 0.75, representing 75% of the viewport height), a pixel
 *     value (e.g. 200px), or any valid CSS length unit (e.g. 10rem). Defaults to 0.75.
 *
 * data-scroll-animation-reverse="true"
 *   - Optional. If set to `"true"`, the animation class will be removed when the element
 *     scrolls back down below the threshold.
 *   - If set to a class name (e.g. `"fade-out"`), that class will be added instead of
 *     just removing the original.
 *   - Reverse animations will only fire after the element has been animated in before.
 *
 * data-scroll-debug="true"
 *   - Optional. If set to true, shows visual lines for threshold and trigger.
 * 
 * data-scroll-children="true"
 *   - Optional. If set to "true", the element's direct children will be animated instead of 
 *     the element itself. Children will use the animation class as specified in
 *     data-scroll-animation-class.
 *   - If set to a class name (e.g. "outline-card"), any descendant elements with that class
 *     will be animated instead of only direct children.
 * 
 * data-scroll-children-stagger="0.1s"
 *   - Optional. Used with data-scroll-children. Sets a staggered delay between each child's 
 *     animation (e.g. "0.1s", "100ms"). If not specified, all children animate simultaneously.
 */

class AttrScrollAnimator {
    constructor(options = {}) {
        this.options = {
            viewportPosition: 0.75,
            ...options
        };

        this.observedElements = new Set();
        this.positionObservers = new Map();
        this.observedThresholds = new Map();
        this.hasAnimatedIn = new Set();
        this.debugLines = new Map();
        this.childrenAnimations = new Map();

        this.debugColors = [
            'hsl(160, 100%, 40%)',
            'hsl(220, 100%, 40%)',
            'hsl(280, 100%, 40%)',
            'hsl(330, 100%, 40%)',
            'hsl(0, 100%, 40%)'
        ];

        this.debouncedRefresh = this.debounce(this.refresh.bind(this), 200);
        window.addEventListener('resize', this.debouncedRefresh);
        window.addEventListener('load', () => this.init());
    }

    parseThreshold(value) {
        if (!value) return this.options.viewportPosition;
        if (value.endsWith('px')) return parseInt(value, 10) / window.innerHeight;
        const parsed = parseFloat(value);
        return !isNaN(parsed) ? parsed : this.options.viewportPosition;
    }

    generateColorForThreshold(index) {
        return this.debugColors[index % this.debugColors.length];
    }

    debounce(fn, delay) {
        let timeout;
        return (...args) => {
            clearTimeout(timeout);
            timeout = setTimeout(() => fn(...args), delay);
        };
    }

    createDebugLabel(text, color) {
        const label = document.createElement('div');
        label.className = 'scroll-animation-debug';
        label.textContent = text;
        Object.assign(label.style, {
            position: 'absolute',
            left: '0',
            top: '-1.2em',
            fontSize: '12px',
            background: 'white',
            color: color,
            padding: '2px 6px',
            border: `1px solid ${color}`,
            borderRadius: '3px'
        });
        label.style.setProperty('visibility', 'visible', 'important');
        label.style.setProperty('display', 'block', 'important');
        label.style.setProperty('opacity', '1', 'important');
        return label;
    }

    createLine(labelText, top, color, fixed = false) {
        const line = document.createElement('div');
        line.className = 'scroll-animation-debug-line scroll-animation-debug';
        Object.assign(line.style, {
            position: fixed ? 'fixed' : 'absolute',
            top: `${top}px`,
            left: '0',
            width: '100%',
            height: '0',
            borderTop: `2px dashed ${color}`,
            zIndex: '9999',
            pointerEvents: 'none'
        });
        line.style.setProperty('visibility', 'visible', 'important');
        line.style.setProperty('display', 'block', 'important');
        line.style.setProperty('opacity', '1', 'important');

        line.appendChild(this.createDebugLabel(labelText, color));
        document.body.appendChild(line);
        return line;
    }

    createPositionObserver(position) {
        if (!this.positionObservers.has(position)) {
            const viewportHeight = window.innerHeight;
            const triggerLinePosition = viewportHeight * position;
            const topMargin = -triggerLinePosition;
            const bottomMargin = -(viewportHeight - triggerLinePosition - 1);

            const observer = new IntersectionObserver(entries => this.handleIntersection(entries), {
                threshold: 0,
                rootMargin: `${topMargin}px 0px ${bottomMargin}px 0px`
            });

            this.positionObservers.set(position, observer);
        }

        return this.positionObservers.get(position);
    }

    parseStaggerDelay(value) {
        if (!value) return 0;
        
        if (value.endsWith('ms')) {
            return parseFloat(value);
        } else if (value.endsWith('s')) {
            return parseFloat(value) * 1000;
        }
        
        return parseFloat(value);
    }

    animateChildren(parent, animateIn) {
        const animationClass = parent.getAttribute('data-scroll-animation-class');
        const childrenAttr = parent.getAttribute('data-scroll-children');
        
        let elements;
        
        if (childrenAttr && childrenAttr !== 'true') {
            elements = Array.from(parent.querySelectorAll(`.${childrenAttr}`)).filter(el => 
                !el.classList.contains('scroll-animation-trigger-line') && 
                !el.classList.contains('scroll-animation-debug')
            );
        } else {
            elements = Array.from(parent.children).filter(child => 
                !child.classList.contains('scroll-animation-trigger-line') && 
                !child.classList.contains('scroll-animation-debug')
            );
        }
        
        if (this.childrenAnimations.has(parent)) {
            this.childrenAnimations.get(parent).forEach(timeout => clearTimeout(timeout));
        }
        
        const reverseAttr = parent.getAttribute('data-scroll-animation-reverse');
        const hasReverseClass = reverseAttr && reverseAttr !== 'true';
        const reverseClass = hasReverseClass ? reverseAttr : null;
        
        const staggerAttr = parent.getAttribute('data-scroll-children-stagger');
        const staggerDelay = staggerAttr ? this.parseStaggerDelay(staggerAttr) : 0;
        
        const timeouts = [];
        
        elements.forEach((element, index) => {
            if (!staggerAttr) {
                if (animateIn) {
                    element.classList.add(animationClass);
                    if (reverseClass) element.classList.remove(reverseClass);
                } else if (reverseAttr) {
                    element.classList.remove(animationClass);
                    if (reverseClass) element.classList.add(reverseClass);
                }
                return;
            }
            
            const delay = index * staggerDelay;
            
            const timeout = setTimeout(() => {
                if (animateIn) {
                    element.classList.add(animationClass);
                    if (reverseClass) {
                        element.classList.remove(reverseClass);
                    }
                } else if (reverseAttr) {
                    element.classList.remove(animationClass);
                    if (reverseClass) {
                        element.classList.add(reverseClass);
                    }
                }
            }, delay);
            
            timeouts.push(timeout);
        });
        
        if (timeouts.length > 0) {
            this.childrenAnimations.set(parent, timeouts);
        }
    }

    handleIntersection(entries) {
        entries.forEach(entry => {
            const element = entry.target;
            const animationClass = element.getAttribute('data-scroll-animation-class');
            const reverseAttr = element.getAttribute('data-scroll-animation-reverse');
            const threshold = this.observedThresholds.get(element) ?? this.options.viewportPosition;

            if (!animationClass) return;

            const triggerLine = window.innerHeight * threshold;
            const elementTop = entry.boundingClientRect.top;

            const hasReverseClass = reverseAttr && reverseAttr !== 'true';
            const reverseClass = hasReverseClass ? reverseAttr : null;
            const childrenAttr = element.getAttribute('data-scroll-children');
            const animateChildren = childrenAttr && childrenAttr !== 'false';

            if (elementTop <= triggerLine) {
                if (!animateChildren) {
                    element.classList.add(animationClass);

                    if (reverseClass) {
                        element.classList.remove(reverseClass);
                    }
                }

                this.hasAnimatedIn.add(element);
                
                if (animateChildren) {
                    this.animateChildren(element, true);
                }
                
            } else if (reverseAttr && this.hasAnimatedIn.has(element)) {
                if (!animateChildren) {
                    element.classList.remove(animationClass);

                    if (reverseClass) {
                        element.classList.add(reverseClass);
                    }
                } else {
                    this.animateChildren(element, false);
                }
            }
        });
    }

    init() {
        const elements = document.querySelectorAll('[data-scroll-animation-class]');

        elements.forEach((element, index) => {
            const animationClass = element.getAttribute('data-scroll-animation-class');
            if (!animationClass || element.classList.contains(animationClass)) return;

            const positionAttr = element.getAttribute('data-scroll-animation-threshold');
            const position = this.parseThreshold(positionAttr);

            const observer = this.createPositionObserver(position);
            observer.observe(element);

            this.observedElements.add(element);
            this.observedThresholds.set(element, position);

            if (element.getAttribute('data-scroll-debug') === 'true') {
                const color = this.generateColorForThreshold(index);

                const drawDebugLines = () => {
                    const thresholdLineEl = this.createLine(`Threshold (${position})`, window.innerHeight * position, color, true);

                    const triggerLineEl = document.createElement('div');
                    triggerLineEl.className = 'scroll-animation-trigger-line scroll-animation-debug';
                    Object.assign(triggerLineEl.style, {
                        position: 'absolute',
                        top: '0',
                        left: '0',
                        width: '100%',
                        height: '0',
                        borderTop: `2px dashed ${color}`,
                        pointerEvents: 'none',
                        zIndex: '9999'
                    });
                    triggerLineEl.style.setProperty('visibility', 'visible', 'important');
                    triggerLineEl.style.setProperty('display', 'block', 'important');
                    triggerLineEl.style.setProperty('opacity', '1', 'important');

                    triggerLineEl.appendChild(this.createDebugLabel(`Trigger - (Threshold ${position})`, color));

                    if (getComputedStyle(element).position === 'static') {
                        element.style.position = 'relative';
                    }

                    element.appendChild(triggerLineEl);
                    this.debugLines.set(element, [triggerLineEl, thresholdLineEl]);
                };

                let frames = 0;
                const waitForStable = () => {
                    if (frames < 5) {
                        frames++;
                        requestAnimationFrame(waitForStable);
                    } else {
                        drawDebugLines();
                    }
                };

                waitForStable();
            }
        });
    }

    refresh() {
        this.destroy();
        this.init();
    }

    destroy() {
        window.removeEventListener('resize', this.debouncedRefresh);

        this.positionObservers.forEach(observer => observer.disconnect());
        this.positionObservers.clear();
        this.observedElements.clear();
        this.observedThresholds.clear();
        this.hasAnimatedIn.clear();

        this.childrenAnimations.forEach(timeouts => {
            timeouts.forEach(timeout => clearTimeout(timeout));
        });
        this.childrenAnimations.clear();

        this.debugLines.forEach(lines => lines.forEach(line => line.remove()));
        this.debugLines.clear();
    }
}

const attrScrollAnimation = new AttrScrollAnimator();
window.attrScrollAnimation = attrScrollAnimation;

Installation tip: Save this script inside your theme or child theme’s JS directory (e.g., /wp-content/themes/your-theme/js/AttrScrollAnimator.min.js ), then enqueue it in your functions.php like so:

PHP
add_action('wp_enqueue_scripts', function () {
    wp_enqueue_script('AttrScrollAnimator', get_stylesheet_directory_uri() . '/js/AttrScrollAnimator.min.js', [], null, true);
});

HTML Examples

Basic example

HTML
<div 
    class="feature-one" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.65">
</div>

With reverse (.fade-in is removed when scrolling back up)

HTML
<div 
    class="feature-one" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.65"
    data-scroll-animation-reverse="true">
</div>

With custom reverse class (.fade-in is replaced with .fade-out when scrolling back up)

HTML
<div 
    class="feature-one" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.65"
    data-scroll-animation-reverse="fade-out">
</div>

Child animations (animates the children instead of the container)

HTML
<div 
    class="card-container" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.65"
    data-scroll-children="true"
    data-scroll-children-stagger="0.2s">
    <div class="card">Card 1</div>
    <div class="card">Card 2</div>
    <div class="card">Card 3</div>
</div>

Child animations with specific class targeting

HTML
<div 
    class="content-section" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.65"
    data-scroll-children="feature-item"
    data-scroll-children-stagger="0.15s">
    <h2>Section Title</h2>
    <div class="feature-item">Feature 1</div>
    <p>Some other text</p>
    <div class="feature-item">Feature 2</div>
    <div class="feature-item">Feature 3</div>
</div>

CSS Examples

Beginner

This is just adding a class to fade in an element when it comes into view.

This isn’t the greatest example because it’s going to have opacity: 0 while you’re editing the page haha. You’d want some kind of selector that disables this while editing the page. In beaver builder, that might look like .fl-builder-edit .feature-one{ opacity: 1; }, but you get the gist!

CSS
.feature-one{
    opacity: 0;
    transition: .2s ease-in-out
}
.feature-one.fade-in {
    opacity: 1;
}

Advanced (fade and slide in/out)

This uses two @keyframes animations to fade in + translate the item, and then fade out + translate when scrolling back up.

CSS
@keyframes fadeSlideIn {
    from {
        opacity: 0;
        transform: translateX(-30px);
    }
    to {
        opacity: 1;
        transform: translateX(0);
    }
}

@keyframes fadeSlideOut {
    from {
        opacity: 1;
        transform: translateX(0);
    }
    to {
        opacity: 0;
        transform: translateX(30px);
    }
}

.fade-in {
    animation: fadeSlideIn 0.6s ease-out forwards;
}

.fade-out {
    animation: fadeSlideOut 0.6s ease-out forwards;
}

Debug Features

When developing animations, it can be challenging to visualize exactly where your trigger thresholds are and how they’re affecting your elements. AttrScrollAnimator includes a built-in visual debugging system to help you fine-tune your animations.

Enabling Debug Mode

To enable debugging for any animated element, simply add the data-scroll-debug="true" attribute:

HTML
<div 
    class="feature-one" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.65"
    data-scroll-debug="true">
</div>

What Debug Mode Provides

When debug mode is enabled for an element, you’ll get clear visual guidelines:

  • A colored dashed line showing the threshold position in the viewport
  • A matching colored line at the top of your element showing the trigger point
  • Each threshold gets a unique color for easier differentiation when debugging multiple elements

The threshold line remains fixed in the viewport at the percentage you’ve specified (defaulting to 75% down the screen if not specified), while the trigger line is attached to the top of your element. When these lines cross, that’s exactly when your animation will trigger.

Debugging Multiple Elements

You can debug multiple elements simultaneously by adding the debug attribute to each one. The script will assign different colors to each threshold to help you distinguish between them:

HTML
<div 
    class="feature-one" 
    data-scroll-animation-class="fade-in"
    data-scroll-animation-threshold="0.5"
    data-scroll-debug="true">
</div>

<div 
    class="feature-two" 
    data-scroll-animation-class="slide-in"
    data-scroll-animation-threshold="0.7"
    data-scroll-debug="true">
</div>

When to Use Debug Mode

Debug mode is particularly useful when:

  • First setting up your animations to visualize threshold positioning
  • Troubleshooting animations that aren’t triggering as expected
  • Coordinating multiple animations with different thresholds
  • Demonstrating scroll behavior to clients or team members

Remember to remove the data-scroll-debug="true" attribute before deploying to production, as the visual indicators are meant for development purposes only.

Wrap-up

This data-attribute–driven scroll animation setup is a great fit when you want precise control over your animations without writing tons of JavaScript or wiring everything to fixed selectors. It plays especially well with page builders, modular content systems, and anywhere editors can access data- attributes.

It gives you:

  • Scroll-based triggering using a percentage of the viewport
  • Optional reversal and class swapping when elements exit
  • Child element animations with optional staggering effects
  • Visual debugging tools for development
  • Full control over animations with your own CSS

It’s clean, lightweight, and extendable, and because everything is configured right in your markup, it’s super easy to manage.

Found this
Snippet
useful?

Consider buying me a coffee!  ☕️  Your support helps me create more free snippets for the WordPress community.