star back image
people4
電飾 電飾
moon
men

【GSAP】ScrollTriggerでスクロール中に要素を固定して内容を切り替えたい

BLOG GSAPWEBログ
読了約:52分

スクロール中にターゲットのdiv要素が、「ブラウザのTOPで固定」されるようにしたいです。さらにスクロールをしていくと、その固定要素の「内部コンテンツがパラパラと差し変わる」ようにしたい。

そんな要件ありますか。

スクロール中に固定する?そんなことできるんですか。

言葉だけだとよくわかりません。

見てみるのが早いです。サンプルを作成しました。

【GSAP】ScrollTriggerでスクロール中に要素を固定サンプル
https://astrowave.jp/amnesia_record/scrolltrigger_gasp_2.php

健忘録リスト

こうした要件の場合は「GSAP」(ジーサップ)を使う方が多いようです。

「GSAP」は派手なアニメーションを仕込みたい時に便利に使えるライブラリ。
有料のプラグインありの少し私には馴染みがなかったのですが、どうやらupdateで無料になったそうです。

DEMO:
https://gsap.com/demos/

4/30のアップデートをもって、有料プラグインも「100%無料」になったとのこと。商用利用も無料です。
https://gsap.com/blog/3-13/

商用利用もOKとは太っ腹ですね。

【共有】ScrollTriggerで固定化のソースコード

サンプルを作成してみましたので共有したいです。

要件は以下です。

  • GSAPのScrollTriggerを使った固定化
  • 固定化した要素の内部のコンテンツを差し替え
  • おまけで横スクロールも入れる
【GSAP】ScrollTriggerでスクロール中に要素を固定サンプル
https://astrowave.jp/amnesia_record/scrolltrigger_gasp_2.php

健忘録リスト

html

<main>
  <section id="anchor_00">
    <div class="mv__inner">
      <div class="mv__video animation--fade">
        <video data-poster-pc="./img/image_fx_4.jpg" poster="./img/image_fx_4.jpg" muted="" playsinline="" loop="loop" class="js-about-video" autoplay>
            <source media="(max-width: 767px)" src="./img/mov_hts-samp003.mp4" type="video/mp4">
            <source media="(min-width: 768px)" src="./img/mov_hts-samp003.mp4" type="video/mp4"></source>
        </video>
        <!-- 低電力モード時の代替画像 -->
        <div class="low-power-fallback" style="display: none;">
          <img src="./img/image_fx_4.jpg" alt="TASAKI HAUTE PARFUMERIE" width="1280" height="800">
        </div>
      </div>
      <div class="mv__scroll">
        <span></span>
      </div>
    </div>

    <div class="js-fixed-fullscreen-trigger example-00">
      <div class="js-fixed-fullscreen-element contents contents-01 active">
        <div class="inner">
          <div class="background">
            <img src="./img/image_fx_3.jpg" alt="">
          </div>
          <div class="foreground">
            <p>SCROLL CONTENT 1</p>
          </div>
        </div>
      </div>
      <div class="js-fixed-fullscreen-element contents contents-02">
        <div class="inner">
          <div class="background">
            <img src="./img/image_fx_2.jpg" alt="">
          </div>
          <div class="foreground">
            <p>SCROLL CONTENT 2</p>
          </div>
        </div>
      </div>
      <div class="js-fixed-fullscreen-element contents contents-03">
        <div class="inner">
          <div class="background">
            <img src="./img/image_fx_1_men2.jpg" alt="">
          </div>
          <div class="foreground">
            <p>SCROLL CONTENT 3</p>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section id="anchor_01">
    <h2>横スクロールで表示するコンテンツ</h2>
    <div class="js-scroll-direction-change-trigger example-01">
      <div class="row">
        <div class="column-large">
          <div class="column-small">
            <h3>横スクロールコンテンツ</h3>
          </div>
          <div class="contents">
            <div class="items js-scroll-direction-change-element">
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
              <div class="item">
                <img src="./img/image_fx_3.jpg" alt="">
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section id="anchor_02">
    <h2>スクロールで全画面のコンテンツ内容を表示切り替え</h2>
    <div class="js-fixed-fullscreen-trigger example-02">
      <div class="js-fixed-fullscreen-element contents contents-01 active">
        <div class="inner">
          <div class="background">
            <img src="./img/image_fx_1_men.jpg" alt="">
          </div>
          <div class="foreground">
            <p>SCROLL CONTENT 4</p>
          </div>
        </div>
      </div>
      <div class="js-fixed-fullscreen-element contents contents-02">
        <div class="inner">
          <div class="background">
            <img src="./img/image_fx_1_women.jpg" alt="">
          </div>
          <div class="foreground">
            <p>SCROLL CONTENT 5</p>
          </div>
        </div>
      </div>
      <div class="js-fixed-fullscreen-element contents contents-03">
        <div class="inner">
          <div class="background">
            <img src="./img/image_fx_1_women2.jpg" alt="">
          </div>
          <div class="foreground">
            <p>SCROLL CONTENT 6</p>
          </div>
        </div>
      </div>
    </div>
  </section>
</main>

<div id="footer">
  <div class="footer-content">
    <h2 class="footer-title">FOOTER</h2>
    <p class="footer-subtitle">通常の高さのフッター</p>
    <div class="footer-links">
      <ul>
      <li><a href="https://astrowave.jp/amnesia_record/">TOP</a></li>
      <li><a href="#">RECRUIT</a></li>
      <li><a href="#">PRIVACY POLICY</a></li>
      <li><a href="#">ABOUT THIS SITE</a></li>
      <li><a href="#">CAUTION</a></li>
      </ul>
      <div class="footer-copyright">
      © 2024 SAMPLE COMPANY. ALL RIGHTS RESERVED.
      </div>
    </div>
  </div>
</div>

53行目からの<section id=”anchor_01″>は、おまけの横スクロール用のhtmlです。
同じくScrollTriggerで発動します。

css

<style>
* {
  box-sizing: border-box;
}
body {
  margin: 0;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
#footer {
  margin-top: 160px;
  width: 100%;
  margin: 0 auto;
}
main {
  width: 100%;
}
h2 {
  font-size: 16px;
  margin: 30px 0;
  padding: 0 30px;
}

.example-01 {
  background: #b1e2ca;
  height: 100vh;
}
.example-01 .row {
  display: flex;
  flex-wrap: wrap;
  height: 100%;
  align-items: center;
  transform: translateY(-30px);
}
.example-01 .column-small {
  width: 100%;
  display: block;
  margin: 30px 0;
  padding: 0 30px;
}
.example-01 .column-large {
  width: calc(100% - 0px);
}
.example-01 .contents {
  overflow-x: hidden;
}
.example-01 .items {
  display: inline-flex;
  gap: 24px;
  padding: 0 24px 0 0;
}
.example-01 .item {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 300px;
  height: 200px;
  background: #888;
  color: #fff;
  flex-shrink: 0;
}
.example-01 .item img {
  max-width: 100%;
  height: auto;
}

.example-00,
.example-02 {
  height: 400vh;
  position: relative;
}
.example-00 .contents,
.example-02 .contents {
  height: 100vh;
  position: sticky;
  top: 0;
  z-index: 1;
  /* 上スクロール時の流れを防ぐ */
  transform: translateZ(0);
  will-change: transform;
}
.example-00 .contents.active,
.example-02 .contents.active {
  z-index: 2;
}
.example-00 .contents .inner,
.example-02 .contents .inner {
  position: sticky;
  top: 0;
  height: 100vh;
}
.foreground p {
  color: #fff;
  cursor: pointer;
  font-size: 2.27vw;
  font-weight: normal;
  text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
}
.example-00 .contents .background img,
.example-00 .contents .foreground p,
.example-02 .contents .background img,
.example-02 .contents .foreground p {
  opacity: 0;
}
.example-00 .contents.active .background img,
.example-00 .contents.active .foreground p,
.example-02 .contents.active .background img,
.example-02 .contents.active .foreground p {
  opacity: 1;
}
.example-00 .contents .background img,
.example-00 .contents .background img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.example-00 .contents.active .background img,
.example-02 .contents.active .background img {
  transition: all 0.2s ease 0.1s;
}
.example-00 .contents .foreground p,
.example-02 .contents .foreground p {
  transform: translate(0, 30px);
  transition: all 1.4s ease 0.8s;
}
.example-00 .contents.active .foreground p,
.example-02 .contents.active .foreground p {
  transform: translate(0, 0);
}
.example-00 .contents .background,
.example-02 .contents .background {
  position: absolute;
  z-index: 1;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
}
.example-00 .contents .background img,
.example-02 .contents .background img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.example-00 .contents .foreground,
.example-02 .contents .foreground {
  position: relative;
  z-index: 2;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 30px;
}
.example-00 .contents .foreground p,
.example-02 .contents .foreground p {
  background: transparent;
  padding: 60px 20px;
  font-size: 2rem;
  font-weight: bold;
  text-align: center;
}

/* フッター(通常の高さ、スタイル) */
#footer {
  width: 100%;
  background: linear-gradient(45deg, #1abc9c, #16a085);
  color: white;
  padding: 100px 0 50px;
  text-align: center;
}
.footer-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
}
.footer-title {
  font-size: 2.5rem;
  font-weight: bold;
  margin-bottom: 2rem;
}
.footer-subtitle {
  font-size: 1.2rem;
  opacity: 0.8;
  margin-bottom: 3rem;
}
.footer-links {
  border-top: 1px solid rgba(255, 255, 255, 0.2);
  padding-top: 50px;
  margin-top: 50px;
}
.footer-links ul {
  list-style: none;
  display: flex;
  justify-content: center;
  gap: 30px;
  flex-wrap: wrap;
  margin-bottom: 30px;
}
.footer-links a {
  color: white;
  text-decoration: none;
  font-size: 14px;
  opacity: 0.8;
  transition: opacity 0.3s ease;
}
.footer-links a:hover {
  opacity: 1;
}
.footer-copyright {
  font-size: 12px;
  opacity: 0.6;
  margin-top: 20px;
}
.footer-title {
  font-size: 1.8rem;
}
.footer-subtitle {
  font-size: 1rem;
}

.mv__inner {
  position: sticky;
  top: 0;
  height: 100vh;
  min-height: 650px;
  width: 100%;
}
.mv__video {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  overflow: hidden
}
.animation--fade {
    -webkit-transform: translate3d(0, 30px, 0);
    transform: translate3d(0, 30px, 0);
    -webkit-transition: opacity 1.3s,-webkit-transform 1.6s cubic-bezier(0.35, 1, 0.7, 1);
    transition: opacity 1.3s,-webkit-transform 1.6s cubic-bezier(0.35, 1, 0.7, 1);
    transition: transform 1.6s cubic-bezier(0.35, 1, 0.7, 1),opacity 1.3s;
    transition: transform 1.6s cubic-bezier(0.35, 1, 0.7, 1),opacity 1.3s,-webkit-transform 1.6s cubic-bezier(0.35, 1, 0.7, 1);
    opacity: 0
}

.animation--fade.is-visible {
    -webkit-transform: translate(0, 0);
    -ms-transform: translate(0, 0);
    transform: translate(0, 0);
    opacity: 1
}
@media screen and (min-width: 768px) {
  .mv__video .js-about-video {
    position: fixed;
    left: 50%;
    top: 50%;
    width: 105vw;
    height: 59.0625vw;
    -webkit-transform: translate(-50%, -50%);
    -ms-transform: translate(-50%, -50%);
    transform: translate(-50%, -50%);
    background: #000;
  }
}
@media screen and (min-width: 768px) and (max-aspect-ratio: 640 / 360) {
  .mv__video .js-about-video {
    width: 180.54vh;
    height: 102vh;
  }
}
@media screen and (max-width: 767px) {
  .mv__video .js-about-video {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 110vw;
    height: 195.547vw;
    -webkit-transform: translate(-50%, -50%);
    -ms-transform: translate(-50%, -50%);
    transform: translate(-50%, -50%);
    background: #000;
  }
}
@media screen and (max-width: 767px) and (max-aspect-ratio: 360 / 640) {
  .mv__video .js-about-video {
    width: calc((102 * var(--svh, 1svh) + 120px) * 1.77);
    height: calc(102 * var(--svh, 1svh) + 120px);
  }
}
.mv__scroll {
  position: absolute;
  left: 50%;
  bottom: 0;
  height: 54px;
  width: 1px;
  background: rgba(255,255,255,.4);
  pointer-events: none;
  z-index: 2
}

.mv__scroll span {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: block;
  background: #fff;
  -webkit-animation: scrollbar-anim 3s ease-out infinite;
  animation: scrollbar-anim 3s ease-out infinite
}
@keyframes scrollbar-anim {
  0% {
    -webkit-transform: scale(1, 1);
    transform: scale(1, 1);
    -webkit-transform-origin: center bottom;
    transform-origin: center bottom
  }
  40% {
    -webkit-transform: scale(1, 0);
    transform: scale(1, 0);
    -webkit-transform-origin: center bottom;
    transform-origin: center bottom
  }
  45% {
    -webkit-transform: scale(1, 0);
    transform: scale(1, 0);
    -webkit-transform-origin: center top;
    transform-origin: center top
  }
  90% {
    -webkit-transform: scale(1, 1);
    transform: scale(1, 1);
    -webkit-transform-origin: center top;
    transform-origin: center top
  }
}
</style>

スクロール中に要素が画面内に固定されているように見えるのは、
CSSのstickyを使用しているためです。

71行目
.example-00 .contents,
.example-02 .contents {
height: 100vh;
position: sticky;
top: 0;
}

85行目
.example-00 .contents .inner,
.example-02 .contents .inner {
position: sticky;
top: 0;
height: 100vh;
}

複数の要素(contents-01、contents-02、contents-03)が
全て同じ位置に重なって配置されており、
スクロールの進行度に応じて active クラスを切り替えることで、
表示する要素を変更しています。

紙芝居のようなイメージですね。

activeクラスは以下のscriptで付け替えています。

javascrips

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.8.0/gsap.min.js"></script>	
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.8.0/ScrollTrigger.min.js"></script>
<script>
  gsap.registerPlugin(ScrollTrigger);

  // sample 0 - anchor_00セクション用
  const anchor00Elements = document.querySelectorAll('#anchor_00 .js-fixed-fullscreen-element');
  const anchor00Container = document.querySelector('#anchor_00 .js-fixed-fullscreen-trigger');

  ScrollTrigger.create({
    trigger: anchor00Container,
    start: 'top top',    // コンテナの上端が画面上端に到達
    end: 'bottom top',   // コンテナの下端が画面上端に到達
    markers: true,
    id: 'anchor_00_trigger',
    onUpdate: (self) => {
      // 進行度に応じてactiveな要素を決定
      const progress = self.progress;
      const totalElements = anchor00Elements.length;

      // 閾値でスクロール量を調整(合計100%)
      // 0.25 + 0.25 + 0.50 = 1.00
      let activeIndex;
      if (progress < 0.25) {
        activeIndex = 0;  // 0〜25% (25%の範囲)
      } else if (progress < 0.50) {
        activeIndex = 1;  // 25〜50% (25%の範囲)
      } else {
        activeIndex = 2;  // 50〜100% (50%の範囲) ← 最後が長い
      }

      // 既にactiveな要素と同じ場合はスキップ
      if (anchor00Elements[activeIndex].classList.contains('active')) {
        return;
      }

      // すべてからactiveを削除して、該当要素にactiveを追加
      anchor00Elements.forEach((item) => item.classList.remove('active'));
      anchor00Elements[activeIndex].classList.add('active');
      console.log(`anchor_00 - active: ${activeIndex} (progress: ${progress.toFixed(3)})`);
    }
  });

  // sample 1
  const targetElement_1 = document.querySelector('.js-scroll-direction-change-element');
  const horizontalScrollTrigger = gsap.fromTo('.js-scroll-direction-change-element', 
    {
      x: 0
    },
    {
      x: `-${targetElement_1.offsetWidth - targetElement_1.parentElement.offsetWidth}`,
      scrollTrigger: {
        id: 'horizontal_scroll', // ID を付けて個別に管理
        trigger: '.js-scroll-direction-change-trigger',
        start: 'top 0%',
        end: () => `+=${targetElement_1.offsetWidth - targetElement_1.parentElement.offsetWidth}`,
        scrub: true,
        markers: true,
        pin: true,
        anticipatePin: 1,
        invalidateOnRefresh: true
      }
    }
  );

  // sample 2 - anchor_02セクション用
  const anchor02Elements = document.querySelectorAll('#anchor_02 .js-fixed-fullscreen-element');
  const anchor02Container = document.querySelector('#anchor_02 .js-fixed-fullscreen-trigger');

  ScrollTrigger.create({
    trigger: anchor02Container,
    start: 'top top',    // コンテナの上端が画面上端に到達
    end: 'bottom top',   // コンテナの下端が画面上端に到達
    markers: false,
    id: 'anchor_02_trigger',
    onUpdate: (self) => {
      const progress = self.progress;

      // 閾値でスクロール量を調整(合計100%)
      let activeIndex;
      // 最後だけ少し長くする場合
      if (progress < 0.30) {
        activeIndex = 0;  // 30%
      } else if (progress < 0.60) {
        activeIndex = 1;  // 30%
      } else {
        activeIndex = 2;  // 40%
      }

      // 既にactiveな要素と同じ場合はスキップ
      if (anchor02Elements[activeIndex].classList.contains('active')) {
        return;
      }

      // すべてからactiveを削除して、該当要素にactiveを追加
      anchor02Elements.forEach((item) => item.classList.remove('active'));
      anchor02Elements[activeIndex].classList.add('active');
      console.log(`anchor_02 - active: ${activeIndex} (progress: ${progress.toFixed(3)})`);
    }
  });
});
</script>
12行目の記述が判断基準になる記述です。

start: 'top top', // コンテナの上端が画面上端に到達
end: 'bottom top', // コンテナの下端が画面上端に到達

endが来ると固定が終わります。

親コンテナ (height: 400vh)

├─ contents-01 (sticky, 透明)
├─ contents-02 (sticky, active) ← activeに切り替わる
└─ contents-03 (sticky, 透明)

全ての要素が同じ位置(top: 0)で固定されているため、
activeが切り替わると、その場で内容が入れ替わって見えます。

activeクラスが着くタイミングで色々とできそうですね。

スクロール量が、最後だけ短く感じませんか?
錯覚ですか?

錯覚ではないような気がします。

均等割でもスクロール量が全く違うように感じます。
色々とAIさんに聞いてみました。

スクロール量の割り当て方法 1

// 閾値でスクロール量を調整(合計100%)
// 0.25 + 0.25 + 0.50 = 1.00
let activeIndex;
if (progress < 0.25) {
activeIndex = 0; // 0〜25% (25%の範囲)
} else if (progress < 0.50) {
activeIndex = 1; // 25〜50% (25%の範囲)
} else {
activeIndex = 2; // 50〜100% (50%の範囲) ← 最後が長い
}
スクロール量の割り当て方法 2

.example-02 .contents-01 { height: 80vh; } /* 小さい */
.example-02 .contents-02 { height: 100vh; } /* 普通 */
.example-02 .contents-03 { height: 120vh; } /* 大きい */

// 80vh + 100vh + 120vh = 300vh(比率: 26.7% + 33.3% + 40%)
if (progress < 0.267) {
activeIndex = 0; // 小さい要素は短く
} else if (progress < 0.600) {
activeIndex = 1; // 0.267 + 0.333 = 0.600
} else {
activeIndex = 2; // 大きい要素は長く
}
// より均等な分割(各要素が均等な範囲を持つ)
let activeIndex;
if (progress < 0.333) {
activeIndex = 0;
} else if (progress < 0.667) {
activeIndex = 1;
} else {
activeIndex = 2;
}

試してみたのですが、あまり変わらないような。。

よくわかっていません。
詳しくはAIに放り込んで聞いてください。

参考サイト置き場

BRISK
GSAPを使って複雑なオープニングアニメーションを作ってみよう
https://b-risk.jp/blog/2022/05/gsap/

フリー動画
HYBRID CREATIVE MOVIE サクラ
https://www.home-movie.biz/free_movie.html

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

どこかで聞いたことがあるような。