AnimationsIntermediateApril 9, 2026
Reveal Group
A polymorphic scroll-reveal wrapper that staggers each direct child into view on scroll. Supports nested groups via RevealNested, per-slot stagger/distance overrides, includeParent, and data-ignore to skip elements. Respects prefers-reduced-motion.
View Full Demo →Preview
Source
demo.jsx
import RevealGroup, { RevealNested } from "./index.jsx";
import styles from "./demo.module.css";
export default function RevealGroupDemo() {
return (
<div className={styles.page}>
{/* Hero */}
<section className={styles.hero}>
<p className={styles.heroLabel}>Scroll to reveal</p>
<h1 className={styles.heroTitle}>Reveal<br />Group</h1>
</section>
{/* Basic group — heading, body, image */}
<section className={styles.section}>
<p className={styles.sectionLabel}>Basic group</p>
<RevealGroup stagger={100} distance="2em" start="top 80%">
<h2 className={styles.heading}>
Every element reveals in sequence.
</h2>
<p className={styles.body}>
Wrap any block of content in RevealGroup and each direct child
animates in with a staggered fade-up on scroll. No extra markup needed.
</p>
<img
src="/demo-assets/kickandbass.png"
alt="Kickandbass"
className={styles.image}
/>
</RevealGroup>
</section>
{/* Nested group — cards */}
<section className={styles.section}>
<p className={styles.sectionLabel}>Nested group</p>
<RevealGroup stagger={120} distance="2.5em" start="top 80%">
<h2 className={styles.heading}>
Nested children stagger independently.
</h2>
<RevealNested stagger={80} distance="1.5em" className={styles.grid}>
<div className={styles.card}>
<img src="/demo-assets/models/model0.png" alt="" className={styles.cardImg} />
</div>
<div className={styles.card}>
<img src="/demo-assets/models/model1.png" alt="" className={styles.cardImg} />
</div>
<div className={styles.card}>
<img src="/demo-assets/models/model2.png" alt="" className={styles.cardImg} />
</div>
</RevealNested>
</RevealGroup>
</section>
{/* includeParent — tags */}
<section className={styles.section}>
<p className={styles.sectionLabel}>Include parent</p>
<RevealGroup stagger={100} distance="2em" start="top 85%">
<h2 className={styles.heading}>
The wrapper itself can join the reveal.
</h2>
<p className={styles.body}>
Set includeParent on RevealNested to animate the container in
before its children cascade out.
</p>
<RevealNested includeParent stagger={60} distance="1em" className={styles.tagGroup}>
<span className={styles.tag}>Motion design</span>
<span className={styles.tag}>Web animation</span>
<span className={styles.tag}>GSAP</span>
<span className={styles.tag}>ScrollTrigger</span>
<span className={styles.tag}>Next.js</span>
</RevealNested>
</RevealGroup>
</section>
<div className={styles.spacer} />
</div>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
// — Nested marker component —
export function RevealNested({
as: Tag = "div",
children,
stagger,
distance,
includeParent = false,
ignore = false,
className = "",
}) {
return (
<Tag
data-reveal-group-nested=""
data-stagger={stagger !== undefined ? stagger : undefined}
data-distance={distance !== undefined ? distance : undefined}
data-ignore={ignore ? "true" : includeParent ? "false" : undefined}
className={className}
>
{children}
</Tag>
);
}
// — Group wrapper component —
export default function RevealGroup({
as: Tag = "div",
children,
stagger = 100,
distance = "2em",
start = "top 80%",
className = "",
}) {
const ref = useRef(null);
useEffect(() => {
const groupEl = ref.current;
if (!groupEl) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const ctx = gsap.context(() => {
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;
}
// Build slots
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 states
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;
slot.nestedChildren.forEach((target) =>
gsap.set(target, { y: nestedD, autoAlpha: 0 })
);
}
});
// Re-assert nested parent distance
slots.forEach((slot) => {
if (slot.type === "nested" && slot.includeParent) {
gsap.set(slot.parentEl, { y: groupDistance });
}
});
// ScrollTrigger reveal
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;
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
);
});
}
});
},
});
}, groupEl);
return () => ctx.revert();
}, [stagger, distance, start]);
return (
<Tag
ref={ref}
data-reveal-group=""
data-stagger={stagger}
data-distance={distance}
data-start={start}
className={className}
>
{children}
</Tag>
);
}
demo.module.css
.page {
background: #f5f4f0;
color: #111;
}
/* Hero */
.hero {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 3rem 5vw;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.heroLabel {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.35);
margin: 0 0 1.5rem;
}
.heroTitle {
font-size: clamp(3.5rem, 10vw, 9rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 0.95;
margin: 0;
}
/* Sections */
.section {
padding: 10vh 5vw;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.sectionLabel {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.35);
margin: 0 0 3rem;
}
/* Basic group items */
.heading {
font-size: clamp(1.75rem, 4vw, 3.5rem);
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0 0 1rem;
max-width: 14em;
}
.body {
font-size: 1.0625rem;
line-height: 1.65;
color: rgba(0, 0, 0, 0.6);
max-width: 38em;
margin: 0 0 2.5rem;
}
.image {
width: 100%;
aspect-ratio: 16 / 7;
object-fit: cover;
border-radius: 0.5em;
display: block;
}
/* Card grid */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
width: 100%;
}
.card {
aspect-ratio: 3 / 4;
border-radius: 0.75em;
overflow: hidden;
background: rgba(0, 0, 0, 0.06);
}
.cardImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Tags */
.tagGroup {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.5rem;
}
.tag {
display: inline-flex;
align-items: center;
padding: 0.4em 0.9em;
border-radius: 100em;
border: 1px solid rgba(0, 0, 0, 0.2);
font-size: 0.8125rem;
letter-spacing: 0.02em;
}
/* Bottom spacer */
.spacer {
height: 20vh;
}
@media (max-width: 767px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
Dependencies
gsap