Elements Reveal on Scroll

A flexible scroll-reveal system using GSAP and ScrollTrigger that animates direct children of a group with configurable stagger, distance, and trigger start. Supports nested groups with independent stagger and distance values, reduced-motion handling, and optional parent inclusion.

gsapscrolltriggerrevealscrollstaggerfadeaccessibility

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>

Code

index.html
html
<!-- Basic group: all direct children reveal in sequence -->
<div data-reveal-group>
  <h2>Heading</h2>
  <p>Paragraph one</p>
  <p>Paragraph two</p>
</div>

<!-- Group with custom stagger, distance, and scroll start -->
<div data-reveal-group data-stagger="150" data-distance="3em" data-start="top 70%">
  <img src="image-1.avif" alt="">
  <img src="image-2.avif" alt="">
  <img src="image-3.avif" alt="">
</div>

<!-- Nested group: parent is skipped, nested children animate independently -->
<div data-reveal-group>
  <h2>Title</h2>
  <div data-reveal-group-nested data-stagger="80">
    <span>Tag one</span>
    <span>Tag two</span>
    <span>Tag three</span>
  </div>
</div>

<!-- Nested group with parent included in main sequence -->
<div data-reveal-group>
  <h2>Title</h2>
  <div data-reveal-group-nested data-ignore="false" data-stagger="80">
    <span>Tag one</span>
    <span>Tag two</span>
    <span>Tag three</span>
  </div>
</div>
script.js
javascript
gsap.registerPlugin(ScrollTrigger);

function initContentRevealScroll() {
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const ctx = gsap.context(() => {

    document.querySelectorAll('[data-reveal-group]').forEach(groupEl => {
      const groupStaggerSec = (parseFloat(groupEl.getAttribute('data-stagger')) || 100) / 1000;
      const groupDistance = groupEl.getAttribute('data-distance') || '2em';
      const triggerStart = groupEl.getAttribute('data-start') || 'top 80%';

      const animDuration = 0.8;
      const animEase = "power4.inOut";

      if (prefersReduced) {
        gsap.set(groupEl, { clearProps: 'all', y: 0, autoAlpha: 1 });
        return;
      }

      const directChildren = Array.from(groupEl.children).filter(el => el.nodeType === 1);
      if (!directChildren.length) {
        gsap.set(groupEl, { y: groupDistance, autoAlpha: 0 });
        ScrollTrigger.create({
          trigger: groupEl,
          start: triggerStart,
          once: true,
          onEnter: () => gsap.to(groupEl, {
            y: 0,
            autoAlpha: 1,
            duration: animDuration,
            ease: animEase,
            onComplete: () => gsap.set(groupEl, { clearProps: 'all' })
          })
        });
        return;
      }

      const slots = [];
      directChildren.forEach(child => {
        const nestedGroup = child.matches('[data-reveal-group-nested]')
          ? child
          : child.querySelector(':scope [data-reveal-group-nested]');

        if (nestedGroup) {
          const includeParent = child.getAttribute('data-ignore') === 'false' || nestedGroup.getAttribute('data-ignore') === 'false';
          slots.push({ type: 'nested', parentEl: child, nestedEl: nestedGroup, includeParent });
        } else {
          slots.push({ type: 'item', el: child });
        }
      });

      slots.forEach(slot => {
        if (slot.type === 'item') {
          const isNestedSelf = slot.el.matches('[data-reveal-group-nested]');
          const d = isNestedSelf ? groupDistance : (slot.el.getAttribute('data-distance') || groupDistance);
          gsap.set(slot.el, { y: d, autoAlpha: 0 });
        } else {
          if (slot.includeParent) gsap.set(slot.parentEl, { y: groupDistance, autoAlpha: 0 });
          const nestedD = slot.nestedEl.getAttribute('data-distance') || groupDistance;
          Array.from(slot.nestedEl.children).forEach(target => gsap.set(target, { y: nestedD, autoAlpha: 0 }));
        }
      });

      slots.forEach(slot => {
        if (slot.type === 'nested' && slot.includeParent) {
          gsap.set(slot.parentEl, { y: groupDistance });
        }
      });

      ScrollTrigger.create({
        trigger: groupEl,
        start: triggerStart,
        once: true,
        onEnter: () => {
          const tl = gsap.timeline();

          slots.forEach((slot, slotIndex) => {
            const slotTime = slotIndex * groupStaggerSec;

            if (slot.type === 'item') {
              tl.to(slot.el, {
                y: 0,
                autoAlpha: 1,
                duration: animDuration,
                ease: animEase,
                onComplete: () => gsap.set(slot.el, { clearProps: 'all' })
              }, slotTime);
            } else {
              if (slot.includeParent) {
                tl.to(slot.parentEl, {
                  y: 0,
                  autoAlpha: 1,
                  duration: animDuration,
                  ease: animEase,
                  onComplete: () => gsap.set(slot.parentEl, { clearProps: 'all' })
                }, slotTime);
              }
              const nestedMs = parseFloat(slot.nestedEl.getAttribute('data-stagger'));
              const nestedStaggerSec = isNaN(nestedMs) ? groupStaggerSec : nestedMs / 1000;
              Array.from(slot.nestedEl.children).forEach((nestedChild, nestedIndex) => {
                tl.to(nestedChild, {
                  y: 0,
                  autoAlpha: 1,
                  duration: animDuration,
                  ease: animEase,
                  onComplete: () => gsap.set(nestedChild, { clearProps: 'all' })
                }, slotTime + nestedIndex * nestedStaggerSec);
              });
            }
          });
        }
      });
    });

  });

  return () => ctx.revert();
}

document.addEventListener("DOMContentLoaded", () => {
  initContentRevealScroll();
});

Guide

Overview

This setup gives you a flexible way to reveal content blocks with GSAP and ScrollTrigger, including support for nested groups with independent staggers and distances. Defaults are provided for everything — only add attributes when you need to override them.

Group

Use [data-reveal-group] on a wrapper to animate all direct children one-by-one. This attribute is required for each group.

Nested

Place [data-reveal-group-nested] on a child so its own children animate in sequence when the parent's slot comes. The parent itself is skipped by default unless [data-ignore="false"] is set.

Stagger

Set [data-stagger] (in milliseconds, default 100ms) to control the delay between animations for direct children or nested children, depending on where it's applied.

Distance

Use [data-distance] (default 2em) to define the starting Y offset for animations. Applies to all children of a group, or only to nested children when set on a nested group.

Start

Set [data-start] (default "top 80%") to define the ScrollTrigger start position for when the group's reveal begins.

Ignore

Add [data-ignore="false"] to a nested group or its parent to include that parent in the main reveal sequence while still animating its nested children independently.

Reduced Motion

If the user has enabled prefers-reduced-motion, all groups are shown immediately with clearProps instead of animating.