スクロール中にターゲットのdiv要素が、「ブラウザのTOPで固定」されるようにしたいです。さらにスクロールをしていくと、その固定要素の「内部コンテンツがパラパラと差し変わる」ようにしたい。
そんな要件ありますか。
スクロール中に固定する?そんなことできるんですか。
言葉だけだとよくわかりません。
見てみるのが早いです。サンプルを作成しました。
【GSAP】ScrollTriggerでスクロール中に要素を固定サンプル
https://astrowave.jp/amnesia_record/scrolltrigger_gasp_2.php
健忘録リスト
こうした要件の場合は「GSAP」(ジーサップ)を使う方が多いようです。
「GSAP」は派手なアニメーションを仕込みたい時に便利に使えるライブラリ。
有料のプラグインありの少し私には馴染みがなかったのですが、どうやらupdateで無料になったそうです。
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
星間旅路のメロディ
「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」
「この電波はどこの星からきたのだろうか。」
どこかで聞いたことがあるような。



