SectionsAdvancedApril 9, 2026
Flip Scale Section
A two-section layout where a small media element grows to fill the screen as you scroll. Uses GSAP Flip to smoothly interpolate position and size between two layout states, driven by ScrollTrigger scrub.
View Full Demo →Preview
Scaling element on scroll
And more content here
Source
demo.jsx
import FlipScaleSection from "./index.jsx";
import { flipScaleSection } from "@/content/sections/demo-data.js";
export default function FlipScaleSectionDemo() {
return <FlipScaleSection {...flipScaleSection} />;
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { Flip } from "gsap/Flip";
import styles from "./styles.module.css";
gsap.registerPlugin(ScrollTrigger, Flip);
export default function FlipScaleSection({
eyebrow,
headingTop,
headingBottom,
mediaSrc,
mediaType = "video",
mediaAlt = "",
scrub = 0.25,
}) {
const outerRef = useRef(null);
const wrapperTopRef = useRef(null);
const wrapperBottomRef = useRef(null);
const targetRef = useRef(null);
useEffect(() => {
const outer = outerRef.current;
const wrapperTop = wrapperTopRef.current;
const wrapperBottom = wrapperBottomRef.current;
const target = targetRef.current;
if (!outer || !wrapperTop || !wrapperBottom || !target) return;
let tl;
let resizeTimer;
function buildTimeline() {
if (tl) {
tl.kill();
gsap.set(target, { clearProps: "all" });
}
const wrappers = [wrapperTop, wrapperBottom];
tl = gsap.timeline({
scrollTrigger: {
trigger: wrappers[0],
start: "center center",
endTrigger: wrappers[wrappers.length - 1],
end: "center center",
scrub,
},
});
wrappers.forEach((wrapper, index) => {
const nextIndex = index + 1;
if (nextIndex < wrappers.length) {
const nextWrapper = wrappers[nextIndex];
const nextRect = nextWrapper.getBoundingClientRect();
const thisRect = wrapper.getBoundingClientRect();
const nextDistance =
nextRect.top + window.scrollY + nextWrapper.offsetHeight / 2;
const thisDistance =
thisRect.top + window.scrollY + wrapper.offsetHeight / 2;
const offset = nextDistance - thisDistance;
tl.add(
Flip.fit(target, nextWrapper, {
duration: offset,
ease: "none",
})
);
}
});
}
buildTimeline();
function onResize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(buildTimeline, 100);
}
window.addEventListener("resize", onResize);
return () => {
clearTimeout(resizeTimer);
window.removeEventListener("resize", onResize);
tl?.scrollTrigger?.kill();
tl?.kill();
gsap.set(target, { clearProps: "all" });
};
}, [scrub]);
return (
<div ref={outerRef} className={styles.outer}>
{/* Top section */}
<section className={styles.headerSection}>
{eyebrow && (
<span className={styles.eyebrow}>{eyebrow}</span>
)}
{headingTop && (
<h2 className={styles.heading}>{headingTop}</h2>
)}
<div className={styles.smallBox}>
<div className={styles.aspectSpacer} />
<div ref={wrapperTopRef} className={styles.flipWrapper}>
<div ref={targetRef} className={styles.mediaTarget}>
{mediaType === "video" ? (
<video
className={styles.video}
src={mediaSrc}
autoPlay
muted
loop
playsInline
/>
) : (
<img
src={mediaSrc}
alt={mediaAlt}
className={styles.image}
/>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="100%"
viewBox="0 0 138 138"
fill="none"
className={styles.overlayIcon}
>
<path
d="M81.7432 46.534C79.5777 48.6995 75.875 47.1659 75.875 44.1034V0.25H62.125V51.8124C62.125 57.5079 57.5079 62.1249 51.8125 62.1249H0.25V75.8749H44.1034C47.1659 75.8749 48.6996 79.5776 46.5341 81.7431L16.0136 112.263L25.7364 121.986L56.2569 91.466C58.416 89.3069 62.1031 90.825 62.125 93.8693V137.75H75.8751L75.875 86.1874C75.875 80.492 80.4921 75.8749 86.1875 75.8749H137.75V62.1249H93.8692C90.8339 62.1031 89.3157 58.4375 91.4469 56.2759L91.4659 56.2569L121.986 25.7363L112.264 16.0137L81.7432 46.534Z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
</section>
{/* Bottom section */}
<section className={styles.videoSection}>
<div className={styles.bigBox}>
<div className={styles.aspectSpacer} />
<div ref={wrapperBottomRef} className={styles.flipWrapper} />
</div>
{headingBottom && (
<h2 className={styles.heading}>{headingBottom}</h2>
)}
</section>
</div>
);
}
styles.module.css
.outer {
position: relative;
overflow: hidden;
}
/* Top section */
.headerSection {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--space-5xl);
min-height: 100vh;
padding: 25vh 5vw 20vh;
position: relative;
}
.eyebrow {
color: #9d420a;
font-size: var(--font-h6);
font-weight: var(--weight-normal);
text-transform: uppercase;
}
.heading {
font-size: clamp(3rem, 7vw, 7rem);
font-weight: var(--weight-medium);
line-height: 1;
text-align: center;
max-width: 9em;
margin: 0 0 var(--space-xs);
}
/* Bottom section */
.videoSection {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 25vh;
padding-bottom: 25vh;
padding-inline: 5vw;
position: relative;
}
/* Boxes */
.smallBox {
border-radius: 1em;
width: 20em;
position: relative;
}
.bigBox {
border-radius: 1em;
width: 100%;
position: relative;
}
/* 16:9 aspect ratio spacer */
.aspectSpacer {
padding-top: 56.25%;
}
/* Flip wrappers */
.flipWrapper {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* Media target — the element Flip moves */
.mediaTarget {
will-change: transform;
background-color: #d2800f;
border-radius: 1em;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
isolation: isolate;
transform: translateX(0) rotate(0.001deg);
}
.video {
object-fit: cover;
width: 100%;
height: 100%;
position: absolute;
border-radius: inherit;
}
.image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.overlayIcon {
color: var(--color-white);
mix-blend-mode: overlay;
width: 6.25em;
position: absolute;
pointer-events: none;
}
/* Mobile */
@media (max-width: 767px) {
.heading {
font-size: 13.5vw;
}
.smallBox {
width: 15em;
}
.overlayIcon {
width: 5em;
}
}
Dependencies
gsap