Code:
JavaScript
<!-- Load GSAP core library (animation engine) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.4/gsap.min.js"></script>
<!-- Load ScrollTrigger plugin (ties GSAP animations to scroll position) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.4/ScrollTrigger.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => { // Run this once the HTML is parsed (before images necessarily finish loading)
gsap.registerPlugin(ScrollTrigger); // Tell GSAP to enable/activate the ScrollTrigger plugin
const cardContainer = document.querySelector(".card-container") || null; // Grab the element that wraps all cards (or null if it doesn't exist)
if (cardContainer) gsap.set(cardContainer, { y: 40, force3D: true }); // If found, start it 40px lower and force GPU acceleration (smoother transforms)
const stickyHeader = document.querySelector(".sticky-header h1") || null; // Grab the sticky section header <h1> (or null if not found)
let isGapAnimationCompleted = false; // Flag to avoid re-triggering the gap/corner animation repeatedly once it’s already applied
let isFlipAnimationCompleted = false; // Flag to avoid re-triggering the flip animation repeatedly once it’s already applied
function initAnimations() { // Wrap all setup so we can re-init cleanly (especially on resize/media changes)
ScrollTrigger.getAll().forEach((trigger) => trigger.kill()); // Kill ALL existing ScrollTriggers on the page (note: global; not scoped to just this section)
const mm = gsap.matchMedia(); // Create a GSAP matchMedia instance (runs different setups depending on screen width)
mm.add("(max-width: 999px)", () => { // For small screens/tablets/mobile
document.querySelectorAll(".card, .sticky-header h1").forEach((el) => // Select all cards and the header
el.removeAttribute("style") // Remove inline styles added by GSAP so mobile layout goes back to “normal” CSS
);
return {}; // Return an empty object/cleanup for this matchMedia rule (nothing special to clean here)
});
mm.add("(min-width: 1000px)", () => { // For desktop screens
const stickyEl = document.querySelector(".sticky"); // Grab the sticky section element that will be pinned
let transformedAncestor = null; // Will store the first parent that has a CSS transform (problematic for pinning)
let prevTransform = ""; // Will store that ancestor’s original inline transform so we can restore it later
let prevWillChange = ""; // Will store that ancestor’s original inline will-change so we can restore it later
if (stickyEl) { // Only run this ancestor scan if .sticky exists
let p = stickyEl.parentElement; // Start from sticky’s parent
while (p && p !== document.body) { // Walk up the DOM until <body> (or until we run out of parents)
const t = getComputedStyle(p).transform; // Read the computed transform of this parent
if (t && t !== "none") { // If this parent actually has a transform applied
transformedAncestor = p; // Remember this parent (it can break fixed/pin behavior)
prevTransform = p.style.transform; // Save its current INLINE transform (not computed) so we can restore it
prevWillChange = p.style.willChange; // Save its current INLINE will-change so we can restore it
p.style.transform = "none"; // Temporarily remove transform so ScrollTrigger pinning works correctly
p.style.willChange = "auto"; // Remove performance hints that can interfere with painting/layout during pin
break; // Stop at the first transformed ancestor (one is enough to cause issues)
}
p = p.parentElement; // Move up one level and continue scanning
}
}
// Target all layers that visually show rounded corners (card wrapper + the faces)
const radiusTargets = gsap.utils.toArray(".card, .card-front, .card-back"); // Convert matching elements into a real array
const originalRadii = radiusTargets.map(el => getComputedStyle(el).borderRadius); // Store each element’s original border-radius so we can restore later
const roundCorners = () => { // Helper: animate corners to a uniform rounded look
gsap.to(radiusTargets, { // Animate these targets
borderRadius: "20px", // Set a consistent 20px border radius
duration: 0.35, // Animate over 0.35s
ease: "power2.out", // Ease out for a smooth “settle”
overwrite: "auto" // Let GSAP intelligently overwrite conflicting tweens on same properties
});
};
const resetCorners = () => { // Helper: animate corners back to whatever they were originally
gsap.to(radiusTargets, { // Animate these targets
borderRadius: (i) => originalRadii[i], // Use a function so each target restores its own saved radius value
duration: 0.35, // Animate over 0.35s
ease: "power2.out", // Same smooth ease
overwrite: "auto" // Prevent tween conflicts
});
};
const st = ScrollTrigger.create({ // Create ONE ScrollTrigger that drives all behaviors in onUpdate
trigger: ".sticky", // The element whose scroll position controls this trigger
start: "top top", // Start when trigger top hits viewport top
end: "+=2200", // End after 2200px of scrolling (creates a “scroll space” for the animation)
scrub: true, // Link animation progress directly to scroll (no timeline; uses onUpdate with progress)
pin: ".sticky", // Pin the .sticky element in place during the scroll duration
pinSpacing: true, // Keep spacing so the rest of the document doesn’t jump upward when pinned
anticipatePin: 1, // Slightly anticipates pinning to reduce jitter on fast scroll
invalidateOnRefresh: true, // Recalculate values on refresh (important when sizes/layout change)
onUpdate: (self) => { // Called continuously while scrolling through this trigger
if (stickyHeader) gsap.set(stickyHeader, { force3D: true }); // Make header transforms GPU accelerated (less flicker)
const progress = self.progress; // Normalized progress from 0 to 1 across the ScrollTrigger’s start/end
// Header animation (fade + slide in)
if (progress >= 0.1 && progress <= 0.25) { // Only animate between 10% and 25% progress
const headerProgress = gsap.utils.mapRange(0.1, 0.25, 0, 1, progress); // Remap progress 0.1..0.25 into 0..1
const yValue = gsap.utils.mapRange(0, 1, 40, 0, headerProgress); // Convert 0..1 into Y offset 40..0
const opacityValue = gsap.utils.mapRange(0, 1, 0, 1, headerProgress); // Convert 0..1 into opacity 0..1
if (stickyHeader) gsap.set(stickyHeader, { y: yValue, opacity: opacityValue }); // Apply the computed Y + opacity to the header
} else if (progress < 0.1 && stickyHeader) { // Before 10% progress, keep it hidden and down
gsap.set(stickyHeader, { y: 40, opacity: 0 }); // Reset header to off position and invisible
} else if (progress > 0.25 && stickyHeader) { // After 25% progress, lock it fully visible in place
gsap.set(stickyHeader, { y: 0, opacity: 1 }); // Header is fully “arrived”
}
// Container width adjustment
if (cardContainer) { // Only if the card container exists
if (progress <= 0.25) { // Only animate width during the first quarter of the scroll
const widthValue = gsap.utils.mapRange(0, 0.25, 75, 80, progress); // Map progress 0..0.25 into width 75..80
cardContainer.style.width = `${widthValue}%`; // Apply width as a percentage string
} else { // After 25% progress
cardContainer.style.width = "80%"; // Keep width fixed at 80%
}
}
// GAP + ROUNDED CORNERS (one-time toggle behavior using a flag)
if (progress >= 0.35 && !isGapAnimationCompleted) { // When passing 35% for the first time
if (cardContainer) gsap.to(cardContainer, { gap: "20px", duration: 0.35, ease: "power2.out", overwrite: "auto" }); // Animate flex gap from 0 to 20px
roundCorners(); // Animate corners to rounded
isGapAnimationCompleted = true; // Mark as completed so we don’t restart this tween every frame
} else if (progress < 0.35 && isGapAnimationCompleted) { // When scrolling back above 35% after it was applied
if (cardContainer) gsap.to(cardContainer, { gap: "0px", duration: 0.35, ease: "power2.out", overwrite: "auto" }); // Animate gap back to 0px
resetCorners(); // Restore original corner shapes
isGapAnimationCompleted = false; // Mark as not completed so it can trigger again next time you cross 35%
}
// Flip cards (one-time toggle behavior using a flag)
if (progress >= 0.6 && !isFlipAnimationCompleted) { // When passing 60% for the first time
const cards = document.querySelectorAll(".card"); // Select all card elements
if (cards.length) { // Only animate if we actually have cards
gsap.to(cards, { // Animate all cards
rotationY: 180, // Flip around Y axis to show the back side (paired with .card-back rotateY(180))
duration: 0.6, // Flip duration
ease: "power3.inOut", // Smooth acceleration + deceleration
stagger: 0.08, // Delay each card a bit for a cascade effect
});
const t1 = document.querySelector("#card-1"); // Grab card 1 specifically (optional)
const t3 = document.querySelector("#card-3"); // Grab card 3 specifically (optional)
if (t1) gsap.to(t1, { y: 30, rotationZ: -15, duration: 0.6 }); // Add a little lift + tilt to card 1 while flipping
if (t3) gsap.to(t3, { y: 30, rotationZ: 15, duration: 0.6 }); // Add a little lift + tilt to card 3 while flipping
}
isFlipAnimationCompleted = true; // Mark flip as completed so we don’t restart every frame
} else if (progress < 0.6 && isFlipAnimationCompleted) { // When scrolling back above 60% after flip happened
const cards = document.querySelectorAll(".card"); // Select all cards again
if (cards.length) { // Only animate if we have cards
gsap.to(cards, { // Animate all cards back
rotationY: 0, // Return to front-facing side
duration: 0.6, // Duration to unflip
ease: "power3.inOut", // Smooth motion
stagger: -0.08, // Reverse stagger for a reverse cascade (negative flips the order)
});
const r1 = document.querySelector("#card-1"); // Grab card 1 again
const r3 = document.querySelector("#card-3"); // Grab card 3 again
if (r1) gsap.to(r1, { y: 0, rotationZ: 0, duration: 0.6 }); // Reset lift/tilt for card 1
if (r3) gsap.to(r3, { y: 0, rotationZ: 0, duration: 0.6 }); // Reset lift/tilt for card 3
}
isFlipAnimationCompleted = false; // Mark flip as not completed so it can trigger again next scroll forward
}
}
});
// Cleanup function for this desktop matchMedia rule
return () => { // GSAP will call this when the media query stops matching (e.g., resizing below 1000px)
st && st.kill(); // Kill this ScrollTrigger instance (st) if it exists
if (transformedAncestor) { // If we removed a transform from an ancestor earlier
transformedAncestor.style.transform = prevTransform; // Restore the ancestor’s original inline transform
transformedAncestor.style.willChange = prevWillChange; // Restore the ancestor’s original inline will-change
}
};
});
}
initAnimations(); // Run the setup immediately after DOMContentLoaded
let resizeTimerSticky; // Will store the debounce timer id for resize events
window.addEventListener("resize", () => { // When the browser window resizes
clearTimeout(resizeTimerSticky); // Cancel any previous scheduled refresh
resizeTimerSticky = setTimeout(() => { // Debounce: wait 250ms after resizing stops
ScrollTrigger.refresh(); // Recalculate pin/start/end positions and layout measurements
}, 250); // Debounce delay
});
window.addEventListener("load", () => setTimeout(() => ScrollTrigger.refresh(), 900)); // After all assets load, refresh again (delay helps if fonts/images shift layout)
});
</script>
CSS
img { /* Targets all <img> elements */
width: 100%; /* Make the image fill the width of its container */
height: 100%; /* Make the image fill the height of its container */
object-fit: cover; /* Crop the image to fill the container without distortion */
}
section { /* Targets all <section> elements */
position: relative; /* Makes absolute-positioned children position relative to this section */
width: 100%; /* Full width section */
box-sizing: border-box; /* Include padding/border inside the element’s width/height */
}
.sticky { /* The pinned/sticky area that ScrollTrigger pins */
position: relative; /* Establish positioning context for absolutely-positioned children */
display: flex; /* Use flex layout for centering content */
justify-content: center; /* Horizontally center flex children */
align-items: center; /* Vertically center flex children */
height: 100vh; /* Full viewport height (so pinning feels like a full-screen panel) */
box-sizing: border-box; /* Padding counts inside the height/width */
padding: 2rem; /* Inner spacing so content isn’t flush to edges */
overflow: hidden; /* Hide overflow so flipped/tilted elements don’t spill outside */
transform: none; /* Explicitly remove transforms (transformed ancestors can break pin/fixed) */
will-change: auto; /* Don’t force will-change here (prevents unnecessary layer creation) */
transition: none; /* will stop the section from jumping on scroll end
}
.sticky-header { /* Wrapper for the title over the cards */
position: absolute; /* Take it out of the normal flow and overlay it */
top: 20%; /* Place it down from the top of the sticky section */
left: 50%; /* Move anchor point to the horizontal center */
transform: translate(-50%, -50%); /* Center the header precisely around its own midpoint */
}
.sticky-header h1 { /* The actual header text element */
position: relative; /* Allows future positioning tweaks if needed */
text-align: center; /* Center-align the header text */
will-change: transform, opacity; /* Hint that transform/opacity will animate (helps performance) */
transform: translateY(40px); /* Default “hidden” position: shifted down */
opacity: 0; /* Default hidden state (JS animates it in) */
}
.card-container { /* Flex container holding the cards */
position: relative; /* Position context for any absolute children (if added later) */
width: 85%; /* Base width (JS later animates between 75-80% and locks at 80%) */
display: flex; /* Lay out the cards side-by-side */
/*gap: 0px; */ /* Gap is commented here; JS animates gap on scroll */
perspective: 1000px; /* Enables 3D perspective for child transforms (flip looks real) */
transform: translateZ(0); /* Nudge into its own GPU layer to reduce paint glitches */
will-change: auto; /* Don’t force will-change constantly (avoids extra layers/memory) */
}
.card { /* The 3D “flip” wrapper */
position: relative; /* Needed because .card-front/.card-back are absolutely positioned */
flex: 1; /* Each card takes equal width in the flex row */
aspect-ratio: 5/7; /* Maintain a consistent card shape (responsive height based on width) */
transform-style: preserve-3d; /* Keep children in 3D space when the parent rotates */
backface-visibility: hidden; /* Helps avoid flickering/bleeding during 3D flips */
will-change: auto; /* Don’t force a permanent layer; GSAP uses transforms anyway */
}
#card-1 { /* Special styling for the first card */
border-radius: 20px 0 0 20px; /* Rounded only on the outer-left corners */
}
#card-3 { /* Special styling for the third card */
border-radius: 0 20px 20px 0; /* Rounded only on the outer-right corners */
}
.card-front,
.card-back { /* Both faces of the card */
position: absolute; /* Stack both faces on top of each other */
width: 100%; /* Fill the full card width */
height: 100%; /* Fill the full card height */
backface-visibility: hidden; /* Hide the reverse face when it’s turned away */
border-radius: inherit; /* Use the parent card’s border radius */
overflow: hidden; /* Clip the image/content to rounded corners */
}
.card-back { /* The back face only */
display: flex; /* Use flex to center its content */
justify-content: center; /* Center content horizontally */
align-items: center; /* Center content vertically */
text-align: center; /* Center any text inside */
transform: rotateY(180deg); /* Pre-rotate the back so when the card flips 180 it reads correctly */
padding: 2rem; /* Add spacing around the back content */
}
Watch on YouTube:
https://youtu.be/oQo6HoE-5PA
https://youtu.be/oQo6HoE-5PA