🚀

Update available

We just released a new resource or update, refresh the Vault to access the latest version.

Cancel

Refresh now

Harri

Profile Picture

Harri

Lemke

Elements Reveal on Scroll

Documentation

Webflow

Code

Setup: External Scripts

HTML

Copy
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/ScrollTrigger.min.js"></script>

Step 1: Add HTML

HTML structure is not required for this resource.

Step 2: Add Javascript

Step 3: Add Javascript

Javascript

Copy
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 => {
      // Config from attributes or defaults (group-level)
      const groupStaggerSec = (parseFloat(groupEl.getAttribute('data-stagger')) || 100) / 1000; // ms → sec
      const groupDistance = groupEl.getAttribute('data-distance') || '2em';
      const triggerStart = groupEl.getAttribute('data-start') || 'top 80%';

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

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

      // If no direct children, animate the group element itself
      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;
      }

      // Build animation slots: item or nested (deep layers allowed)
      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') !== 'true' &&
            (
              child.getAttribute('data-ignore') === 'false' ||
              nestedGroup.getAttribute('data-ignore') === 'false'
            );

          const nestedChildren = Array.from(nestedGroup.children).filter(
            el => el.nodeType === 1 && el.getAttribute('data-ignore') !== 'true'
          );

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

      // Initial hidden state
      slots.forEach(slot => {
        if (slot.type === 'item') {
          // If the element itself is a nested group, force group distance (prevents it from using its own data-distance)
          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 {
          // Parent follows the group's distance when included, regardless of nested's data-distance
          if (slot.includeParent) gsap.set(slot.parentEl, { y: groupDistance, autoAlpha: 0 });
          // Children use nested group's own distance (fallback to group distance)
          const nestedD = slot.nestedEl.getAttribute('data-distance') || groupDistance;
          slot.nestedChildren.forEach(target => gsap.set(target, { y: nestedD, autoAlpha: 0 }));
        }
      });

      // Extra safety: if a nested parent is included, re-assert its distance to the group's value
      slots.forEach(slot => {
        if (slot.type === 'nested' && slot.includeParent) {
          gsap.set(slot.parentEl, { y: groupDistance }); 
        }
      });

      // Reveal sequence
      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 {
              // Optionally include the parent at the same slot time (parent uses group distance)
              if (slot.includeParent) {
                tl.to(slot.parentEl, {
                  y: 0,
                  autoAlpha: 1,
                  duration: animDuration,
                  ease: animEase,
                  onComplete: () => gsap.set(slot.parentEl, { clearProps: 'all' })
                }, slotTime);
              }
              // Nested children use nested stagger (ms → sec); fallback to group stagger
              const nestedMs = parseFloat(slot.nestedEl.getAttribute('data-stagger'));
              const nestedStaggerSec = isNaN(nestedMs) ? groupStaggerSec : nestedMs / 1000;
              slot.nestedChildren.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();
}

// Initialize Elements Reveal on Scroll
document.addEventListener("DOMContentLoaded", () => {
  initContentRevealScroll();
});

Implementation

This setup gives you a flexible way to reveal content blocks with GSAP + ScrollTrigger, including support for nested groups with independent staggers and distances. You don’t need to set every attribute on each group because defaults are provided for everything. Only add attributes when you need to override the defaults.

1. Wrap your reveal items inside a container with the data-reveal-group attribute. This will animate all of the direct children elements inside this wrapper:

<div data-reveal-group>
  <!-- children to reveal -->
</div>
Copy

2. Inside your data-reveal-group, you can add any HTML structure.

  • Direct children will animate in sequence based on the group’s data-stagger.
  • If a direct child contains data-reveal-group-nested, that nested group’s children will reveal in their own sequence.
  • By default, the parent of a nested group is skipped from the main reveal. Add data-ignore="false" to the parent (or nested group) if you also want it included in the main group’s animation.

3. Here's a quick example structure with comments to explain what happens:

<div data-reveal-group>
  <img> <!-- this item will reveal -->
  <img> <!-- this item will reveal -->
  <div data-reveal-group-nested> <!-- this item will NOT reveal -->
    <img> <!-- this item will reveal -->
    <img> <!-- this item will reveal -->
    <img> <!-- this item will reveal -->
  </div> 
  <div data-reveal-group-nested data-ignore="false"> <!-- this item WILL reveal -->
    <img> <!-- this item will reveal -->
    <img> <!-- this item will reveal -->
    <img> <!-- this item will reveal -->
  </div>   
</div>
Copy

Available attributes

Group

Use [data-reveal-group] on a wrapper to animate all direct children one-by-one (default) unless a child contains a nested group; required for each group.

Nested

Place [data-reveal-group-nested] on a child so its own children animate in sequence when the parent’s turn comes, skipping the parent by default unless data-ignore=“false” is set.

Stagger

Set [data-stagger] (default 100 ms) 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 offset for animations, applying 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.

Skip Element

Use [data-ignore="true"] on any element to exclude it from the animation. This works on direct children of a group as well as children inside a nested group, without affecting the rest of the sequence.

Include Parent

Add [data-ignore="false"] to a nested group or its parent to include the parent element in the main reveal sequence alongside its nested children.

Live preview

Osmo Robot AI

Copy context for AI

Beta

Webflow

HTML/CSS/JS

Save video

Copy share link

Resource details

  • Published

    August 13, 2025

  • Category

    Scroll Animations

  • Popularity

    4.6K visitors

  • Need help?

    Join Slack

Scrolling
Scrolltrigger
GSAP
Stagger
Reveal
Animation
Setup
Ilja van EckIlja van Eck

Creator Credits

We always strive to credit creators as accurately as possible. While similar concepts might appear online, we aim to provide proper and respectful attribution.

s