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 →
[ About ]

Scaling element on scroll

And more content here

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;
  }
}
  • gsap

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 4, 2026

SECTIONS

Portfolio Grid

A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.

May 1, 2026

SECTIONS

Logo Wall Cycle

A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.