AnimationsIntermediateApril 27, 2026

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.

View Full Demo →

Parallax hero

This section moves on the y axis while the next section scrolls in.

No transition

Static content block

A normal section in the flow. No data attribute — it just scrolls naturally.

Pinned section

This section stays fixed while the next one wipes over it.

After the pin

Keep scrolling

This section scrolled over the pinned one above.

Almost there

One more section before the reveal.

Revealed from behind

This section rises from behind the one before it.

demo.jsx
import SectionTransition01 from "./index.jsx";

const sections = [
  {
    imageSrc: "/demo-assets/card-sample-1.jpg",
    heading: "Parallax hero",
    body: "This section moves on the y axis while the next section scrolls in.",
    mode: "parallax",
    y: 300,
    opacity: 0.75,
  },
  {
    bg: "#0a0a0a",
    eyebrow: "No transition",
    heading: "Static content block",
    body: "A normal section in the flow. No data attribute — it just scrolls naturally.",
  },
  {
    imageSrc: "/demo-assets/card-sample-2.jpg",
    heading: "Pinned section",
    body: "This section stays fixed while the next one wipes over it.",
    mode: "pin",
  },
  {
    bg: "#141414",
    eyebrow: "After the pin",
    heading: "Keep scrolling",
    body: "This section scrolled over the pinned one above.",
  },
  {
    bg: "#0a0a0a",
    heading: "Almost there",
    body: "One more section before the reveal.",
  },
  {
    imageSrc: "/demo-assets/card-sample-3.jpg",
    heading: "Revealed from behind",
    body: "This section rises from behind the one before it.",
    mode: "reveal",
    y: 240,
    opacity: 0.5,
  },
];

export default function SectionTransition01Demo() {
  return <SectionTransition01 sections={sections} />;
}
index.jsx
"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";

gsap.registerPlugin(ScrollTrigger);

/* ── GSAP section transition engine ─────────────────────────── */

function sectionTransition01(scopeOrConfig = document, maybeConfig = {}) {
  const DEFAULT_CONFIG = {
    parallaxY: 300,
    revealY: 0,
    overlayColor: "black",
    mobile: {
      breakpoint: 768,
      strategy: "simplify",
    },
  };

  const isScope = (v) => v instanceof Element || v instanceof Document;

  const getConfig = (overrides = {}) => ({
    ...DEFAULT_CONFIG,
    ...overrides,
    mobile: { ...DEFAULT_CONFIG.mobile, ...(overrides.mobile || {}) },
  });

  const getYValue = (section, fallback) => {
    const v = parseFloat(section.dataset.stY || String(fallback));
    return Number.isNaN(v) ? fallback : v;
  };

  const getOpacityValue = (section) => {
    const v = parseFloat(section.dataset.stOpacity || "");
    if (Number.isNaN(v)) return null;
    return Math.max(0, Math.min(1, v));
  };

  const getOverlayColor = (section, fallback) =>
    section.dataset.stOverlay || fallback;

  const getOverlayElement = (section, color) => {
    let overlay = section.querySelector("[data-st-overlay-el]");
    if (!overlay) {
      overlay = document.createElement("div");
      overlay.setAttribute("data-st-overlay-el", "");
      overlay.setAttribute("aria-hidden", "true");
      section.append(overlay);
    }
    if (getComputedStyle(section).position === "static") {
      section.style.position = "relative";
    }
    section.style.isolation = "isolate";
    Object.assign(overlay.style, {
      position: "absolute",
      inset: "0",
      zIndex: "2",
      pointerEvents: "none",
      background: color,
      opacity: "0",
      willChange: "opacity",
    });
    return overlay;
  };

  const resetOverlay = (section) => {
    const el = section.querySelector("[data-st-overlay-el]");
    if (el) gsap.set(el, { opacity: 0 });
  };

  const getConfiguredYValue = (section, mode, cfg) => {
    if (mode === "reveal") return getYValue(section, cfg.revealY);
    if (mode === "parallax") return getYValue(section, cfg.parallaxY);
    return 0;
  };

  const isMobileViewport = (cfg) =>
    window.matchMedia(`(max-width: ${cfg.mobile.breakpoint}px)`).matches;

  const getMobileStrategy = (cfg) => {
    const allowed = new Set(["same", "disable", "simplify"]);
    return allowed.has(cfg.mobile.strategy)
      ? cfg.mobile.strategy
      : DEFAULT_CONFIG.mobile.strategy;
  };

  const hasYMotion = (mode, y) =>
    mode === "parallax" || (mode === "reveal" && y !== 0);

  const resolveTransition = (mode, y, strategy, isMobile) => {
    if (!isMobile || strategy === "same" || !hasYMotion(mode, y))
      return { mode, y };
    if (strategy === "disable") return { mode: "none", y: 0 };
    if (mode === "parallax") return { mode: "pin", y: 0 };
    return { mode, y: 0 };
  };

  const scope = isScope(scopeOrConfig) ? scopeOrConfig : document;
  const config = getConfig(isScope(scopeOrConfig) ? maybeConfig : scopeOrConfig);
  const mobileStrategy = getMobileStrategy(config);
  const isMobile = isMobileViewport(config);
  const sections = scope.querySelectorAll("[data-st-01]");

  sections.forEach((section) => {
    const configuredMode = section.getAttribute("data-st-01") || "parallax";
    const configuredY = getConfiguredYValue(section, configuredMode, config);
    const opacity = getOpacityValue(section);
    const { mode, y } = resolveTransition(
      configuredMode,
      configuredY,
      mobileStrategy,
      isMobile
    );

    if (mode === "none") {
      resetOverlay(section);
      return;
    }

    /* ── reveal ── */
    if (mode === "reveal") {
      const prev = section.previousElementSibling;
      if (!prev) return;

      gsap.set(prev, { zIndex: 1 });
      gsap.set(section, { position: "sticky", bottom: 0, zIndex: 0 });

      if (opacity === null) resetOverlay(section);
      if (y === 0 && opacity === null) return;

      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: prev,
          start: "bottom bottom",
          end: () => `+=${section.offsetHeight}`,
          scrub: true,
        },
      });

      if (y !== 0) {
        tl.fromTo(section, { y }, { y: 0, ease: "none", force3D: true }, 0);
      }

      if (opacity !== null) {
        const overlay = getOverlayElement(
          section,
          getOverlayColor(section, config.overlayColor)
        );
        gsap.set(overlay, { opacity });
        tl.to(overlay, { opacity: 0, ease: "none" }, 0);
      }
      return;
    }

    /* ── parallax / pin need a next sibling ── */
    const next = section.nextElementSibling;
    if (!next) return;

    /* ── pin ── */
    if (mode === "pin") {
      ScrollTrigger.create({
        trigger: next,
        start: "top bottom",
        end: "top top",
        pin: section,
        pinSpacing: false,
      });

      if (configuredMode === "parallax" && opacity !== null) {
        const overlay = getOverlayElement(
          section,
          getOverlayColor(section, config.overlayColor)
        );
        gsap
          .timeline({
            scrollTrigger: {
              trigger: next,
              start: "top bottom",
              end: "top top",
              scrub: true,
            },
          })
          .to(overlay, { opacity, ease: "none" }, 0);
        return;
      }

      resetOverlay(section);
      return;
    }

    /* ── parallax ── */
    const scrollTrigger = {
      trigger: next,
      start: "top bottom",
      end: "top top",
      scrub: true,
    };

    const tween = { y, ease: "none", force3D: true };

    if (opacity === null) {
      resetOverlay(section);
      gsap.to(section, { ...tween, scrollTrigger });
      return;
    }

    const overlay = getOverlayElement(
      section,
      getOverlayColor(section, config.overlayColor)
    );
    gsap
      .timeline({ scrollTrigger })
      .to(section, tween, 0)
      .to(overlay, { opacity, ease: "none" }, 0);
  });
}

/* ── React component ────────────────────────────────────────── */

export default function SectionTransition01({ sections = [] }) {
  const mainRef = useRef(null);

  useEffect(() => {
    const el = mainRef.current;
    if (!el) return;

    const ctx = gsap.context(() => {
      sectionTransition01(el);
    }, el);

    return () => ctx.revert();
  }, []);

  return (
    <div ref={mainRef} className={styles.main}>
      {sections.map((s, i) => {
        const dataAttrs = {};
        if (s.mode) dataAttrs["data-st-01"] = s.mode;
        if (s.y != null) dataAttrs["data-st-y"] = String(s.y);
        if (s.opacity != null) dataAttrs["data-st-opacity"] = String(s.opacity);
        if (s.overlayColor) dataAttrs["data-st-overlay"] = s.overlayColor;

        return (
          <section
            key={i}
            className={styles.section}
            {...dataAttrs}
          >
            {s.imageSrc && (
              <img
                className={styles.bg}
                src={s.imageSrc}
                alt=""
                loading={i === 0 ? "eager" : "lazy"}
                decoding="async"
              />
            )}

            {s.videoSrc && (
              <video
                className={styles.bg}
                src={s.videoSrc}
                autoPlay
                muted
                loop
                playsInline
              />
            )}

            {(s.imageSrc || s.videoSrc) && (
              <div className={styles.mediaDim} />
            )}

            <div
              className={styles.inner}
              style={s.bg ? { backgroundColor: s.bg } : undefined}
              data-align={s.align || "center"}
            >
              {s.eyebrow && <p className={styles.eyebrow}>{s.eyebrow}</p>}
              {s.heading && <h2 className={styles.heading}>{s.heading}</h2>}
              {s.body && <p className={styles.body}>{s.body}</p>}
            </div>
          </section>
        );
      })}
    </div>
  );
}
styles.module.css
.main {
  position: relative;
}

/* ── Sibling sections ───────────────────────────────────────── */
.section {
  position: relative;
  min-height: 100vh;
  z-index: 1;
  overflow: hidden;
}

.section:not([data-st-01="pin"]) {
  will-change: transform;
}

/* ── Background media ───────────────────────────────────────── */
.bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}

.mediaDim {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  pointer-events: none;
}

/* ── Content inner ──────────────────────────────────────────── */
.inner {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 100vh;
  padding: var(--space-16) var(--space-8);
  color: #fff;
}

.inner[data-align="center"] {
  align-items: center;
  text-align: center;
}

.inner[data-align="left"] {
  align-items: flex-start;
  text-align: left;
}

.eyebrow {
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin-bottom: var(--space-4);
  opacity: 0.7;
}

.heading {
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: var(--weight-bold);
  letter-spacing: -0.03em;
  line-height: var(--leading-tight);
  max-width: 18ch;
}

.body {
  margin-top: var(--space-6);
  font-size: var(--text-lg);
  max-width: 42ch;
  line-height: var(--leading-relaxed);
  opacity: 0.8;
}
  • 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

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.

Apr 9, 2026

ANIMATIONS

Cascading Slider

GSAP-driven carousel that positions slides across 7 slots (active, left/right siblings, far left/right, and two hidden staging slots). Slides clip-path into view as they move, with the active slide title fading in after the transition completes. Keyboard and click navigable.