
Documentation
Webflow
Code
Setup: External Scripts
External Scripts in Webflow
Make sure to always put the External Scripts before the Javascript step of the resource.
In this video you learn where to put these in your Webflow project? Or how to include a paid GSAP Club plugin in your project?
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>
Step 1: Copy structure to Webflow
Copy structure to Webflow
In the video below we described how you can copy + paste the structure of this resource to your Webflow project.
Copy to Webflow
Webflow structure is not required for this resource.
Step 1: Add HTML
HTML
HTML structure is not required for this resource.
Step 2: Add CSS
CSS
Step 2: Add custom Javascript
Custom Javascript in Webflow
In this video, Ilja gives you some guidance about using JavaScript in Webflow:
Step 2: Add Javascript
Step 3: Add Javascript
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 => {
// 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') === 'false' || nestedGroup.getAttribute('data-ignore') === 'false';
slots.push({ type: 'nested', parentEl: child, nestedEl: nestedGroup, includeParent });
} else {
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;
Array.from(slot.nestedEl.children).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;
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();
})
Step 3: Add custom CSS
Step 2: Add custom CSS
Custom CSS in Webflow
Curious about where to put custom CSS in Webflow? Ilja explains it in the below video:
CSS
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>
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>
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.
Ignore
Add [data-ignore="false"]
to include a nested group’s parent in the main reveal sequence while still animating its nested children.
Resource Details
Last updated
August 13, 2025
Type
The Vault
Category
Scroll Animations
Need help?
Join Slack