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.
Setup — External Scripts
<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
<!-- 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>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.