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 →Preview

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.
Source
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;
}
Dependencies
gsap