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 →
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;
  }
}
  • gsap

Apr 27, 2026

ANIMATIONS

Cursor Hover Label

A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.