🐧

Sassオンリーでパララックスな画像ループを作ってみた

2023/04/28に公開

はじめに

オサレなサイトでよく見る、画像が無限ループするやつ、ありますよね。
それをちょっとパララックス(視差効果)を入れて実装してみました。

本来ならば、SwiperのparallaxなりGSAPなりを使った方が早いのかも知れませんが
やりたいことを紙に書いていくうちに、「あれ、これJSいらなくない?」となったので
気合いで実装しました。
正直、執筆開始時点ではなぜ動いているのかわからないこともありましたが
図式化して解説することで、解決できましたので
思考過程を記載しようと思います。

実現できた挙動

  • スライド枚数
  • スライドサイズ(均一px)
  • スライド間の余白(均一px)
  • ループ時間(duration)
  • スライドサイズに対する画像のサイズ

上記の値をsass変数で管理しています。
それらを任意の値に設定すれば、パララックスな画像ループを生成してくれるように実装できました。

コード全体像と実装結果

先に、実装結果は以下です。

コード全体像は以下です

index.html
<div class="loop-wrapper">
   <div class="loop-track">
     <ul class="loop-list">
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li> 
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
      </ul>
      <ul class="loop-list loop-list__dummy">
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li> 
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
      </ul>
   </div>
 </div>
index.scss
@use "sass:math";

//==============================編集必要箇所==============================
$duration: 30s; //ループが一周するのにかかる時間
$slideNum: 7; //ループさせる画像の枚数
$slideWidth: 300; //単位はpx(利用時に自動で付与されます)
$slideHeight: 300; //単位はpx(利用時に自動で付与されます)
$gapWidth: 75; //slide間の長さ。単位はpx(利用時に自動で付与されます)

//以下は任意で変更。
$imgWidthPercent: 150;
//imgのliに対するwidth比率。単位は%(単位は利用時に自動で付与されます)
//この値を調整することで視差効果の強度を調整できます。

$windowBaseWidth:2000;//基準となるwindowサイズ(この値が実際のwindowサイズより大きければ挙動は実現可能)

//※注意点※
//slideNum,slideWidth,gapWidthの値を使用してlistの幅(後述listWidth)を決定しています
//それらの値が小さすぎると、listの幅がviewportよりも小さくなり、挙動が実現できなくなります。
//値をある程度大きくして調整して下さい。
//==========================編集必要箇所ここまで==========================

//==========================編集しないで下さい============================
$listWidth: ($slideNum * $slideWidth) + ($gapWidth * $slideNum); //listの幅。これは画面幅よりも大きい値である必要があります。
$imgRatio: math.div($imgWidthPercent, 100);
$imgWidthPx: $slideWidth * $imgRatio;
$imgSurplusWidthPx: $imgWidthPx - $slideWidth;
$translateBase:math.div($imgSurplusWidthPx, $imgWidthPx) * 100; //liからはみ出た部分がちょうど内側に入るまでに必要なtranslateXの%
$translatePercentPerPx: math.div(
  $translateBase,
  $windowBaseWidth - $slideWidth
); //1pxでtranslateが何%動くか
$translateTotal: $listWidth * $translatePercentPerPx; //durationの間にtranslateさせる合計値
$translateShiftPercent:$translatePercentPerPx * ($slideWidth + $gapWidth);
//==========================編集しないで下さい=========================

.loop-wrapper {
  overflow-x: hidden;

  //デバッグ用========================================================
  //横スクロール可能にする時は以下を有効化
  overflow-x: scroll;
  //デバッグ用ここまで==================================================
}

//デバッグ用==========================================================
//アニメーションを停止させるには以下を有効化
// .loop-track {
//   animation-play-state: paused !important;

//   .loop-slide {
//     img {
//       animation-play-state: paused !important;
//     }
//   }
// }

//デバッグ用ここまで============================================================

.loop-track {
  display: flex;
  height: #{$slideHeight}px;
  width: calc(#{$listWidth} * 2px);
  animation: loop $duration linear infinite;
  @keyframes loop {
    0% {
      translate: 0 0;
    }
    100% {
      translate: calc(-1px * #{$listWidth}) 0;
    }
  }
}

.loop-slide {
  width: #{$slideWidth}px;
  height: 100%;
  overflow: hidden;
  position: relative;

  img {
    object-fit: cover;
    position: absolute;
    right: 0;
    top: 0;
    height: 100%;
    width: calc(#{$imgWidthPercent} * 1%);
  }

  $lastNum: $slideNum;
  @for $i from 1 through $lastNum {
    &:nth-of-type(#{$i}) {
      img {
        $translateNewBase:$translateBase - ($translateShiftPercent * ($i - 1));
        animation: parallax#{$i} infinite $duration linear;
        translate: calc(#{$translateNewBase} * 1%) 0%;

        @keyframes parallax#{$i} {
          0% {
            translate: calc(#{$translateNewBase} * 1%) 0%;
          }
          100% {
            translate: calc((#{$translateNewBase} + #{$translateTotal}) * 1%) 0%;
          }
        }
      }
    }
  }
}

.loop-list {
  display: flex;
  width: #{$listWidth}px;
  gap: #{$gapWidth}px;

  //クローンスライド
  &.loop-list__dummy {
    .loop-slide {
      $startNum: $slideNum + 1;//for文開始値
      $lastNum: $startNum + $slideNum - 1;//for文終了値

      @for $i from $startNum through $lastNum {
        $num: $i - $slideNum;

        &:nth-of-type(#{$num}) {

          img{
            $translateNewBase:$translateBase - ($translateShiftPercent * ($i - 1));
            animation: parallax#{$i} infinite $duration linear;
            translate: calc(#{$translateNewBase} * 1%) 0%;
  
            @keyframes parallax#{$i} {
              0% {
                translate: calc(#{$translateNewBase} * 1%) 0%;
              }
              100% {
                translate: calc((#{$translateNewBase} + #{$translateTotal}) * 1%) 0%;
              }
            }
          }
        }
      }
    }
  }
}

scssコード中では、for文を用いてループ処理を行うことで任意の値に対応できるようにしています。
しかし、最初は固定値で考えループ化しました。固定値で考えた過程を後述します。

解説

ここから、コードの解説に入ります。
以下では、上記コードで定義している変数を固定値で解説しています。
値は以下です。

index.scss
$duration:15s; //ループが一周するのにかかる時間
$slideNum: 4; //ループさせる画像の枚数
$slideWidth: 300; //単位はpx(利用時に自動で付与されます)
$slideHeight: 300; //単位はpx(利用時に自動で付与されます)
$gapWidth: 75; //slide間の長さ。単位はpx(利用時に自動で付与されます)
$listWidth:1500px;

1. 画像ループ

何はともあれ、画像をループさせなければ何も始まりません。
ポイントとしては、以下です。

  • 画像ループの描画領域を作る(wrapper)
  • ループさせる画像のリストを作る(list)
  • ダミーの画像リストを作る(list)
  • 二つのリストをtrackで囲む
  • trackをtranslateさせて左へ移動させるキーフレームアニメーションを作る
  • (gapサイズ * 画像枚数) + (画像サイズ * 画像枚数) = listのwidthとなるようにする
  • ダミーのリストの先頭の画像がちょうど画面左端にきたら、アニメーションが最初に戻るように
    durationを調整する。

肝となるのは、最後二つです。
図を用いて説明します。
最終のコードはfor文を用いて、任意の画像サイズやgapに対応していますが
解説は固定値で行います。

  • 画像枚数 4枚
  • 画像サイズ 300px * 300px(縦は実装に無関係です)
  • gapサイズ 75px
  • duration 15s
  • 画面幅 1000px

とします。

DOM構造を図式化したものは以下です。

実際に描画されるのは、wrapperの範囲内です。
スタイルの当て方は以下。

  • wrapperにoverflow:hidden(デバッグしやすいようにscrollでも可)
  • trackとlistにdisplay:flex

(gapサイズ * 画像枚数) + (画像サイズ * 画像枚数) = listのwidthとなるようにする
理由となる画像は以下です。

こうすることで、gapと同じだけの幅の余りがリスト内に生まれます。
すると、ダミーのリストと本物のリストの間にもgapと同様の隙間を作ることができます。

次に、trackをキーフレームアニメーションで左方向へ移動させます。(負方向へのtranslate)
移動時間は、最初に定義したduration(15s)です。
移動距離は以下の画像を参照して下さい。

ダミーのリストの最初の画像が画面左端に到達したタイミングで、キーフレームが終わり
最初から再開するようにすれば、永遠に4枚の画像がループしているように見えます。

そのため、移動させたい距離は「リストの横幅」と一致します。
以上のことより、trackへ当てるキーフレームアニメーションとスタイルは以下です。

index.scss
.loop-track {
  display: flex;
  height:300px;
  width:1500px;
  animation: loop 15s linear infinite;
  @keyframes loop {
    0% {
      translate:0 0;
    }
    100% {
      translate:-1500px 0;
    }
  }
}

ここまでの実装で、画像をループさせて表示させることができました。
この実装だけでも、今まではsplideなどのスライダーライブラリを用いて行っていたので
CSSオンリーでできたことに感動しました。
しかし、今回目指すのはパララックス画像ループです。
先へ進めます。

2.パララックス

挙動の確認

ひとまず、パララックス(視差効果)がどのように生み出されているのかを説明します。
イメージとしては、車窓を思い浮べて頂けると分かりやすいかと思います。

手前のものは早く、遠くのものはゆっくり動いている見えますよね。
それを、画像スライダーの中で行います。

もう一度、DOM構造を確認してみましょう。

index.html
<div class="loop-wrapper">
   <div class="loop-track">
     <ul class="loop-list">
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li> 
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
      </ul>
      <ul class="loop-list">
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt=""/>
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
       <li class="loop-slide">
         <img src="path" alt="" />
       </li> 
       <li class="loop-slide">
         <img src="path" alt="" />
       </li>
      </ul>
   </div>
 </div>

wrapper>ul>li>img
という構造になっていますが、liタグが窓の役割です。
そして、imgが景色を表しています。

liタグからimgをはみ出させて、liタグにoverflow:hidden
そして、画像をtrackの移動方向と逆にゆっくり移動させてあげると
遠くの景色がゆっくり移動しているように見えて、スライダーに奥行き感が生まれます。

ここで注意が必要なのが、画像をliタグの幅に収まらず動かしてしまえば
隙間ができてしまって、何も表示されない領域ができてしまいます。
そのような状態は望ましくありません。
(下記画像参照)

なので、理想としては
slide(liタグ)が画面内に入ったら、画像がslide右端に位置しており、slideが画面左端に来たら
画像がslideの左端にくるような状態です。

これを実現するためには、いくつかの値を考える必要があります。

imgを移動させる距離(%)は?

まずは、画面内において画像をどれだけ移動させる必要があるかどうかです。
ここでは、画像のサイズをslideに対して150%と仮定します。

動かしたい幅は、slideからはみ出ている部分の距離になりますが
汎用性を高めるために、固定値(px)ではなく%で求めたいと思います。
ここでの割合は、slideサイズに対する割合ではなく、画像のサイズに対する割合を求める必要があります。
理由は、画像をtranslate(%)で動かしていくためです。

求め方は下記画像を参照して下さい。

つまり、viewportも合わせて図式化すると以下のような形が理想です。
(※imgはposition:absolute,right:0で初期位置をli右端にしています。

)

各スライド位置におけるtranslateの初期値は?

trackの移動状態をまず、図で見てみましょう。

図から分かるように、trackは15sかけて1500px移動していますが
imgを0%から33%まで15秒で移動させてしまったら、ずれてしまいます。
理由は、4枚目のスライドが画面外にあり、translateの初期値が0%ではないためです。
従って、4枚目のスライドのtranslateの初期値から、33%までを15s秒かけて移動させる必要があることがわかります。

初期値を求めるために、imgを1pxごとに何%translateさせれば良いかを計算してみます。

ここで、一つ気をつけないといけないことは
最後のスライド(4枚目のスライド)までの長さとlistの長さは異なる点です。
理由としては、list内にgapと同じだけの「あまり」があるためと、
translateの基準位置がslideの左側であることです。

そのため、imgは33%から0%まで変化するまでに、trackは
1000px(window幅) - 300px(スライド幅) = 700px移動することになります。
このことから、% / pxを求めることができます。

33% / 700px = 0.047%

これでimgを1px毎に0.047%移動させれば良いことがわかりました。
あとは、slideの位置によって初期値を設定すれば良いです。

スライドはslideの幅(300px) + gap(75)px = 375pxずつ間隔が空いているため
translateも、375px * 0.047% = 17.625%ずつずらして行けば良いことになります。

よって各スライド位置における、translateの初期値は以下になります。

1枚目のスライド:translate:33% 0%
2枚目のスライド:translate:translate:15.375% 0%
3枚目のスライド:translate:-2.25% 0%;
4枚目のスライド:translate:-19.875% 0%;

imgを15s間でどれだけ移動させれば良いのか?

これは、先ほどの % / pxを用いれば簡単に求めることができます。

trackは15sで1500px移動するので
1500px * 0.047% = 70.5%

つまり、imgは15sで70.5%分translateさせれば良いことになります。
以上のことから、各スライドのimgにキーフレームアニメーションを割り当てていきます。

コードは以下です。

index.scss
.loop-slide {
  width:300pxpx;
  height: 100%;
  overflow: hidden;
  position: relative;

  img {
    object-fit: cover;
    position: absolute;
    right: 0;
    top: 0;
    height: 100%;
    width:150%;
  }
  &:nth-of-type(1){
    img{
      translate:33% 0%;
      animation:parallax1 infinite 15s linear;
      @keyframes parallax1{
        0%{
          translate:33% 0%;
        }
        100%{
          translate:103.5% 0%;
        }
      }
    }
  }
  &:nth-of-type(2){
    img{
      translate:15.375% 0%;
      animation:parallax2 infinite 15s linear;
      @keyframes parallax2{
        0%{
          translate:15.375% 0%;
        }
        100%{
          translate:85.875% 0%;
        }
      }
    }
  }
  &:nth-of-type(3){
    img{
      translate:-2.25% 0%;
      animation:parallax3 infinite 15s linear;
      @keyframes parallax3{
        0%{
          translate:-2.25% 0%;
        }
        100%{
          translate:68.25% 0%;
        }
      }
    }
  }
  &:nth-of-type(4){
    img{
      translate:-19.875% 0%;
      animation:parallax4 infinite 15s linear;
      @keyframes parallax4{
        0%{
          translate:-19.875% 0%;
        }
        100%{
          translate:50.625% 0%;
        }
      }
    }
  }
}

ダミーのリストのキーフレームを考える

次に、ダミーリストのキーフレームを考えていきます。
ロジック的には同様ですが、挙動を確認します。

ダミーリストは、上記のように15sかけて、画面左端に到達します。
移動距離は、同様に1500px(リスト幅)です。

imgのtranslateの初期値ですが、そもそものスタート地点が異なるため
本物リストからさらに、ずらす必要があります。
ずらす値を求める必要がありますが、スライドは本物リストもダミーリストも均一なgapで並べられています。
そのため、先ほど求めたずらす値を流用することができます。

再確認ですが、本物リストのtranslateの初期値は以下です。

1枚目のスライド:translate:33% 0%
2枚目のスライド:translate:translate:15.375% 0%
3枚目のスライド:translate:-2.25% 0%;
4枚目のスライド:translate:-19.875% 0%;

各スライド間で、375px * 0.047% = 17.625%ずつずらしています。
これに続いて、ダミーリストも17.625%ずつずらせば問題ありません。

ダミーリストのtranslateの初期値

1枚目のスライド:translate: -37.5% 0%
2枚目のスライド:translate:translate: -55.125 0%
3枚目のスライド:translate: -72.75% 0%
4枚目のスライド:translate: -90.375% 0%

15s間で移動する距離も、本物リストと同様のため、キーフレームアニメーションは以下になります。

index.scss
.loop-list {
  display: flex;
  width:3000px;
  gap:75px;

  //クローンスライド
  &.loop-list__dummy {
    .loop-slide {
      &:nth-of-type(1) {
        img {
          translate: -37.5% 0%;
          animation: parallax5 infinite 15s linear;
          @keyframes parallax5 {
            0% {
              translate: -37.5% 0%;
            }
            100% {
              translate: 33% 0%;
            }
          }
        }
      }
      &:nth-of-type(2) {
        img {
          translate: -55.125% 0%;
          animation: parallax6 infinite 15s linear;
          @keyframes parallax6 {
            0% {
              translate: -55.125 0%;
            }
            100% {
              translate: 15.375% 0%;
            }
          }
        }
      }
      &:nth-of-type(3) {
        img {
          translate: -72.75% 0%;
          animation: parallax7 infinite 15s linear;
          @keyframes parallax7 {
            0% {
              translate: -72.75% 0%;
            }
            100% {
              translate: 2.25% 0%;
            }
          }
        }
      }
      &:nth-of-type(4) {
        img {
          translate: -90.375% 0%;
          animation: parallax8 infinite 15s linear;
          @keyframes parallax8 {
            0% {
              translate: -90.375% 0%;
            }
            100% {
              translate: -19.875% 0%;
            }
          }
        }
      }
    }
  }
}

これで、1000pxのwindow幅の時に4枚の画像を視差効果をつけてループさせることができました。

汎用的なコードへ書き換える

ここまでで、window幅や画像サイズなど、全てを固定値で限定した条件下において
画像に視差効果をつけてループさせる実装方法がわかりました。

しかし、実際は画像のサイズやgapなどを自由に調整したいことが多いと思います。
カスタマイズが可能なようにコードを汎用化してみます。

基準となる変数を用意する

まずは、実装者が任意に変更したいであろう値を変数で用意します。
内容は以下です。

index.scss
$duration: 30s; //ループが一周するのにかかる時間
$slideNum: 7; //ループさせる画像の枚数
$slideWidth: 300; //単位はpx(利用時に自動で付与されます)
$slideHeight: 300; //単位はpx(利用時に自動で付与されます)
$gapWidth: 75; //slide間の長さ。単位はpx(利用時に自動で付与されます)
$imgWidthPercent: 150;//slideWidthに対するimgの幅比率

必要な値を求める

次に、挙動を実装するために必要な値を考えてみます。
最終的に必要な値は以下となります。

  • $durationの間にtrackを移動させる長さ
  • imgをtranslateさせる値
  • 各スライド間でtranslateをずらす値
  • $durationの間にimgを移動させるtranslate値

順番に求めていきます。

$durationの間にtrackを移動させる長さ

これは、listの長さを求めれば良いと思います。
コードは以下です。

index.scss
$listWidth: ($slideNum * $slideWidth) + ($gapWidth * $slideNum); //listの幅。

gapに関しては、通常であればslide間のみにしかないため、gapの数はスライドの枚数 - 1となるはずですが
今回の実装方法では、list内にgapと同じ長さの「余り」があるため、画像枚数と同じ数だけのgapが生まれます。
以上のことより、listの長さがわかったため、この値を用いればtrackのスタイルが当てられると思います。
コードは以下です。

index.scss
.loop-track {
  display: flex;
  height: #{$slideHeight}px;
  width: calc(#{$listWidth} * 2px);
  animation: loop $duration linear infinite;
  @keyframes loop {
    0% {
      translate: 0 0;
    }
    100% {
      translate: calc(-1px * #{$listWidth}) 0;
    }
  }
}

trackのwidthはlistの2倍になるため、コード内でcalcで付与しています。
また、変数内で単位を設定しないのは、計算をしやすくするためです。

imgをtranslateさせる値

求め方は以下です。

index.scss
//$imgimgWidthPercentで設定した値を、乗算で利用できるように100で徐算する
$imgRatio: math.div($imgWidthPercent, 100);

//imgの幅を求める
$imgWidthPx: $slideWidth * $imgRatio;

//はみ出ている部分を求める
$imgSurplusWidthPx: $imgWidthPx - $slideWidth;

//translateさせる値を求める(はみ出ている部分が占めるimgのwidthに対する割合)
$translateBase:math.div($imgSurplusWidthPx, $imgWidthPx) * 100

各スライド間でtranslateをずらす値

まずは、下記の式で1px毎にimgを何%移動させれば良いかを求めます。

index.scss
$translatePercentPerPx: 
math.div(
  $translateBase,
  window幅 - $slideWidth
);

ここで問題なのは、windowの幅をCSS(SCSS)では動的に取得することが不可能であることです。
上記の式で、先ほど求めたimgを移動させる値をwindowの幅からスライドの幅を引いた値で割っています。
この方法は、前述したwindow幅1000pxの時に行った方法と同様のものです。

しかし、windowの幅を取得することができないため、基準となるwindow幅を実装者が決定する必要があります。

そもそも、このwindow幅は何を意味しているかと言うと
指定したwindow幅において、slideが画面内に入ってきたタイミングで
imgがちょうどslideの右端にあり、slideが画面左端に来たタイミングで
imgがslideの左端にくる
という挙動を実現できる幅ということになります。

再掲しますが、参考画像は以下です。

仮に、基準のwindow幅を2000pxとすると
2000pxの時に上記の状態を実現できるということになります。

では、基準のwindow幅意外の時はどうなるのか。

基準よりも大きいサイズの画面で挙動を確認すると
trackの右側の基準windowよりはみ出た箇所の画像が見切れます。

基準より小さいサイズの画面で見ると
slideが画面内に入ってきた時に、imgはslideのちょうど右側ではなく
過ぎた位置にあることになりますが、
slideが左端に到達した地点では、ちょうどslideの左端にあります。
そのため、画像が見切れることはなく、不自然になることもありません。

しかし、slide内で見えるimgの位置が変わるため
window間で見栄えの多少の差異が出てくる可能性があります。
気になる時は、メディアクエリで基準のwindow幅のみ変更すれば良いと思います。

基準となるwindow幅を設けることで、imgを1px毎に移動させるtranslate値は以下になります

index.scss
$windowBaseWidth:2000;//windowの基準となる幅

$translatePercentPerPx: math.div(
  $translateBase,
  $windowBaseWidth - $slideWidth
); //1pxでtranslateが何%動くか

次に、各スライド間でどれだけtranslateをずれせば良いかを考えます。

index.scss
//各スライド間でずらすtranslate値
$translateShiftPercent:$translatePercentPerPx * ($slideWidth + $gapWidth);

これも、window1000pxの時に求めた方法と同様です。

$durationの間にimgを移動させるtranslate値

最後に、durationの間にimgをtranslateさせる値を求めます。

index.scss
$translateTotal: $listWidth * $translatePercentPerPx; //durationの間にtranslateさせる合計値

これは、listの長さの間にどれだけ移動させるかを求めれば良いので、上記式になります。
以上で、必要な変数を用意できました。
用意した変数は以下です。

index.scss
//==============================編集必要箇所==============================
$duration: 30s; //ループが一周するのにかかる時間
$slideNum: 7; //ループさせる画像の枚数
$slideWidth: 300; //単位はpx(利用時に自動で付与されます)
$slideHeight: 300; //単位はpx(利用時に自動で付与されます)
$gapWidth: 75; //slide間の長さ。単位はpx(利用時に自動で付与されます)

//以下は任意で変更。
$imgWidthPercent: 150;
//imgのliに対するwidth比率。単位は%(単位は利用時に自動で付与されます)
//この値を調整することで視差効果の強度を調整できます。

$windowBaseWidth:2000;//基準となるwindowサイズ(この値が実際のwindowサイズより大きければ挙動は実現可能)

//※注意点※
//slideNum,slideWidth,gapWidthの値を使用してlistの幅(後述listWidth)を決定しています
//それらの値が小さすぎると、listの幅がviewportよりも小さくなり、挙動が実現できなくなります。
//値をある程度大きくして調整して下さい。
//==========================編集必要箇所ここまで==========================

//==========================編集しないで下さい============================
$listWidth: ($slideNum * $slideWidth) + ($gapWidth * $slideNum); //listの幅。これは画面幅よりも大きい値である必要があります。
$imgRatio: math.div($imgWidthPercent, 100);
$imgWidthPx: $slideWidth * $imgRatio;
$imgSurplusWidthPx: $imgWidthPx - $slideWidth;
$translateBase:math.div($imgSurplusWidthPx, $imgWidthPx) * 100; //liからはみ出た部分がちょうど内側に入るまでに必要なtranslateXの%
$translatePercentPerPx: math.div(
  $translateBase,
  $windowBaseWidth - $slideWidth
); //1pxでtranslateが何%動くか
$translateTotal: $listWidth * $translatePercentPerPx; //durationの間にtranslateさせる合計値
$translateShiftPercent:$translatePercentPerPx * ($slideWidth + $gapWidth);

for文を用いて本物リストのキーフレームアニメーションを定義する

あとは、定義した変数を1000pxの時と同様にキーフレームに当てはめて行くのみです。
コードは以下です。

index.scss
.loop-slide {
  width: #{$slideWidth}px;
  height: 100%;
  overflow: hidden;
  position: relative;

  img {
    object-fit: cover;
    position: absolute;
    right: 0;
    top: 0;
    height: 100%;
    width: calc(#{$imgWidthPercent} * 1%);
  }

  $lastNum: $slideNum;
  @for $i from 1 through $lastNum {
    &:nth-of-type(#{$i}) {
      img {
        $translateNewBase:$translateBase - ($translateShiftPercent * ($i - 1));
        animation: parallax#{$i} infinite $duration linear;
        translate: calc(#{$translateNewBase} * 1%) 0%;

        @keyframes parallax#{$i} {
          0% {
            translate: calc(#{$translateNewBase} * 1%) 0%;
          }
          100% {
            translate: calc((#{$translateNewBase} + #{$translateTotal}) * 1%) 0%;
          }
        }
      }
    }
  }
}

ここでのポイントは二つです。

一つ目のポイントです。
for文の終了値は、スライドの枚数によって変化するため、
スライドの枚数をそのまま終了値としています。

二つ目のポイントです。
translateさせる値の初期値をループが進む毎に$translateShiftPercentずつずらしたいので、
ループ内で新たに$translateNewBaseという変数を定義しています。
その変数内には、初回ループ時には($i = 0)何も加えず
2回目のループ時より、$translateShiftPercentを加算できるような式を記載しています。

あとは、キーフレームアニメーションの中で
初期値を$translateNewBaseにして
終了値は、translateNewBaseに先ほど求めたtranslateTotalを足しています。

for文を用いてダミーリストのキーフレームアニメーションを定義する

実装方法は、ほぼ変わりません。
コードは以下です。

index.scss

.loop-list {
  display: flex;
  width: #{$listWidth}px;
  gap: #{$gapWidth}px;

  //クローンスライド
  &.loop-list__dummy {
    .loop-slide {
      $startNum: $slideNum + 1;//for文開始値
      $lastNum: $startNum + $slideNum - 1;//for文終了値

      @for $i from $startNum through $lastNum {
        $num: $i - $slideNum;

        &:nth-of-type(#{$num}) {

          img{
            $translateNewBase:$translateBase - ($translateShiftPercent * ($i - 1));
            animation: parallax#{$i} infinite $duration linear;
            translate: calc(#{$translateNewBase} * 1%) 0%;
  
            @keyframes parallax#{$i} {
              0% {
                translate: calc(#{$translateNewBase} * 1%) 0%;
              }
              100% {
                translate: calc((#{$translateNewBase} + #{$translateTotal}) * 1%) 0%;
              }
            }
          }
        }
      }
    }
  }
}

ポイントは二つです。

一つ目は、for文の開始値と終了値を変数で定義している点です。
開始値は、本物リストの次になるのでスライド枚数+1となります。
終了値は、開始値+スライド枚数です。

二つ目は、新たに変数numを定義している点です。
変数numは1からループ毎に1ずつ増加する値です。
これを利用して、nth-of-typeを指定しています。
そのほかの実装方法は、本物リストの時と同様です。

最終的に完成したコードは以下です。

index.scss
@use "sass:math";

//==============================編集必要箇所==============================
$duration: 30s; //ループが一周するのにかかる時間
$slideNum: 7; //ループさせる画像の枚数
$slideWidth: 300; //単位はpx(利用時に自動で付与されます)
$slideHeight: 300; //単位はpx(利用時に自動で付与されます)
$gapWidth: 75; //slide間の長さ。単位はpx(利用時に自動で付与されます)

//以下は任意で変更。
$imgWidthPercent: 150;
//imgのliに対するwidth比率。単位は%(単位は利用時に自動で付与されます)
//この値を調整することで視差効果の強度を調整できます。

$windowBaseWidth:2000;//基準となるwindowサイズ(この値が実際のwindowサイズより大きければ挙動は実現可能)

//※注意点※
//slideNum,slideWidth,gapWidthの値を使用してlistの幅(後述listWidth)を決定しています
//それらの値が小さすぎると、listの幅がviewportよりも小さくなり、挙動が実現できなくなります。
//値をある程度大きくして調整して下さい。
//==========================編集必要箇所ここまで==========================

//==========================編集しないで下さい============================
$listWidth: ($slideNum * $slideWidth) + ($gapWidth * $slideNum); //listの幅。これは画面幅よりも大きい値である必要があります。
$imgRatio: math.div($imgWidthPercent, 100);
$imgWidthPx: $slideWidth * $imgRatio;
$imgSurplusWidthPx: $imgWidthPx - $slideWidth;
$translateBase:math.div($imgSurplusWidthPx, $imgWidthPx) * 100; //liからはみ出た部分がちょうど内側に入るまでに必要なtranslateXの%
$translatePercentPerPx: math.div(
  $translateBase,
  $windowBaseWidth - $slideWidth
); //1pxでtranslateが何%動くか
$translateTotal: $listWidth * $translatePercentPerPx; //durationの間にtranslateさせる合計値
$translateShiftPercent:$translatePercentPerPx * ($slideWidth + $gapWidth);
//==========================編集しないで下さい=========================

.loop-wrapper {
  overflow-x: hidden;

  //デバッグ用========================================================
  //横スクロール可能にする時は以下を有効化
  overflow-x: scroll;
  //デバッグ用ここまで==================================================
}

//デバッグ用==========================================================
//アニメーションを停止させるには以下を有効化
// .loop-track {
//   animation-play-state: paused !important;

//   .loop-slide {
//     img {
//       animation-play-state: paused !important;
//     }
//   }
// }

//デバッグ用ここまで============================================================

.loop-track {
  display: flex;
  height: #{$slideHeight}px;
  width: calc(#{$listWidth} * 2px);
  animation: loop $duration linear infinite;
  @keyframes loop {
    0% {
      translate: 0 0;
    }
    100% {
      translate: calc(-1px * #{$listWidth}) 0;
    }
  }
}

.loop-slide {
  width: #{$slideWidth}px;
  height: 100%;
  overflow: hidden;
  position: relative;

  img {
    object-fit: cover;
    position: absolute;
    right: 0;
    top: 0;
    height: 100%;
    width: calc(#{$imgWidthPercent} * 1%);
  }

  $lastNum: $slideNum;
  @for $i from 1 through $lastNum {
    &:nth-of-type(#{$i}) {
      img {
        $translateNewBase:$translateBase - ($translateShiftPercent * ($i - 1));
        animation: parallax#{$i} infinite $duration linear;
        translate: calc(#{$translateNewBase} * 1%) 0%;

        @keyframes parallax#{$i} {
          0% {
            translate: calc(#{$translateNewBase} * 1%) 0%;
          }
          100% {
            translate: calc((#{$translateNewBase} + #{$translateTotal}) * 1%) 0%;
          }
        }
      }
    }
  }
}

.loop-list {
  display: flex;
  width: #{$listWidth}px;
  gap: #{$gapWidth}px;

  //クローンスライド
  &.loop-list__dummy {
    .loop-slide {
      $startNum: $slideNum + 1;//for文開始値
      $lastNum: $startNum + $slideNum - 1;//for文終了値

      @for $i from $startNum through $lastNum {
        $num: $i - $slideNum;

        &:nth-of-type(#{$num}) {

          img{
            $translateNewBase:$translateBase - ($translateShiftPercent * ($i - 1));
            animation: parallax#{$i} infinite $duration linear;
            translate: calc(#{$translateNewBase} * 1%) 0%;
  
            @keyframes parallax#{$i} {
              0% {
                translate: calc(#{$translateNewBase} * 1%) 0%;
              }
              100% {
                translate: calc((#{$translateNewBase} + #{$translateTotal}) * 1%) 0%;
              }
            }
          }
        }
      }
    }
  }
}

まとめ

解説は以上です。パララックスな画像ループを作ってみようと思って
紙に書きながら試行錯誤してみました。
意外とCSSでできることは多く、奥深さを感じました。

今回は、SCSSオンリーで実装しました。
しかし、実際に実務で利用する際、
前述したようにデザインの再現性を厳密に管理するために
JSでinnerWidthをとって、CSS変数を操作して動的にwindowBaseWidthを変更した方が良いと思います。
その際は、SCSS変数をJSから操作することができないため
変数を全てCSS変数に置き換える必要があります。

今回記事にまとめてみて
CSSの可能性に気付けたと共に、限界も知ることができ、とても良い学びでした。
お読み頂き、ありがとうございました。

Discussion