GSAP & Elementor Split Image into cards on scroll

SHARE

SIGN UP FOR THE NEWSLETTER

Your subscription could not be saved. Please try again.
Your subscription has been successful.

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

SIGN UP FOR THE NEWSLETTER

Your subscription could not be saved. Please try again.
Your subscription has been successful.