JavaScript

jQueryで作る!サムネイル付きスライダーの実装テクニック【自作スライダー応用編】

みなと

本記事では、「サムネイル連動型の自作スライダー」をテーマに、より実践的なスライダーの実装方法を解説していきます。

スライダーの基本的な構造や、自動スライド・フリック操作・ループ機能といった処理については、以下の記事で詳しく紹介しました。

あわせて読みたい
自作スライダー完全解説!jQueryを活用してスライダーを実装する方法とメリット
自作スライダー完全解説!jQueryを活用してスライダーを実装する方法とメリット

今回はその発展版として、サムネイルの連動表示や、複数スライダーの同時対応といった新たな機能を加えた構成になっています。

今回も、jQuery を使って HTML・CSS・JavaScript の構成を一つひとつ解説していきますので、コードを見ながらじっくり学べる内容になっています。Web制作の現場で実際に使えるクオリティを目指して、実装の工夫や拡張のヒントも交えながらご紹介します。

実際に動かしてみよう(デモ)

まずは、今回解説するサムネイル付きの自作スライダーがどのように動作するのか、以下のデモで実際に確認してみてください。このスライダーは、スライド数に応じてサムネイルの挙動が自動的に変化します。

  • 6つ以上の場合:スライドの移動に合わせてサムネイルも横に移動し、常に中央のサムネイルが現在のスライドを示します。
  • 5つ以下の場合:サムネイル自体は移動せず、現在のスライドに対応するサムネイルだけがハイライト表示されます。

また、今回のスライダーは、1ページ内に複数設置しても、それぞれが独立して正常に動作するよう設計されています。スマートフォンでもPCでも直感的に扱えるよう設計されており、スムーズな操作性を体感できます。

6つ以上の場合

ヴェネツィアの静かな運河沿いの街並みと停泊するボート、左手にはカフェでくつろぐ人々の姿

カラフルな建物が並ぶヴェネツィアの運河沿い。
穏やかな水面にはボートが浮かび、静かな日常が流れている。

独立型バスタブと天然素材の洗面台が調和する高級感あるモダンなバスルーム

木と石の質感が美しく調和したモダンなバスルーム。
独立型バスタブが空間にゆとりをもたらし、上質な時間を演出します。

大きなガラス窓から自然光が差し込む、観葉植物とカラフルな椅子が並ぶモダンなロビー

ガラス越しに街の景色を望むロビー。
自然光が心地よく差し込み、カラフルなインテリアとグリーンが調和した開放的な空間。

紅葉した街路樹とレンガの建物が並ぶ静かな住宅街の通り

秋の光がやさしく差し込む静かな通り。
色づいた街路樹とレンガの建物が調和し、心和む風景をつくり出しています。

黄色いソファと丸いクッションが置かれた日当たりの良いリビングルーム

ビタミンカラーのソファと差し込む陽射しが心地よい、開放感あふれるリビング。
リラックスと明るさが共存する空間。

ネイビーブルーのソファとアート作品が飾られたリビングの壁面

壁一面を彩るアートと、深みのあるブルーのソファが映えるこだわりのリビング空間。
小物使いにも個性が光ります。

木製の本棚と観葉植物、レザーソファが置かれた落ち着いたインテリア空間

木の温もりが感じられる本棚に、グリーンとレザーのアクセント。
読書やくつろぎの時間を心地よく演出するコーナー。

5つ以下の場合

ヴェネツィアの静かな運河沿いの街並みと停泊するボート、左手にはカフェでくつろぐ人々の姿

カラフルな建物が並ぶヴェネツィアの運河沿い。
穏やかな水面にはボートが浮かび、静かな日常が流れている。

独立型バスタブと天然素材の洗面台が調和する高級感あるモダンなバスルーム

木と石の質感が美しく調和したモダンなバスルーム。
独立型バスタブが空間にゆとりをもたらし、上質な時間を演出します。

大きなガラス窓から自然光が差し込む、観葉植物とカラフルな椅子が並ぶモダンなロビー

ガラス越しに街の景色を望むロビー。
自然光が心地よく差し込み、カラフルなインテリアとグリーンが調和した開放的な空間。

紅葉した街路樹とレンガの建物が並ぶ静かな住宅街の通り

秋の光がやさしく差し込む静かな通り。
色づいた街路樹とレンガの建物が調和し、心和む風景をつくり出しています。

機能一覧

このデモでは、以下の機能を実装しています。

自動スライド一定時間ごとにスライドが自動で切り替わります。
前後ナビゲーション左右の矢印ボタンで、前後のスライドに移動可能です。
フリック&ドラッグ操作スマートフォンではフリック、PCではドラッグ操作でスライドできます。
サムネイルとの連動サムネイル画像をクリックすると、対応するスライドに直接ジャンプします。
ループ機能最後のスライドから最初にスムーズに戻る無限ループを実現。
ビューポート感知で自動開始スライダーが画面内に表示されたタイミングで自動スライドを開始します。
レスポンシブ対応画面幅に応じてレイアウトが最適化されます。
複数スライダー対応ページ内に複数のスライダーを設置しても、それぞれ独立して動作します。

実際の動作を確認したら、次のセクションではコードの中身を詳しく見ていきます。HTML、CSS、JavaScriptの各ファイルはすべてコピペでそのまま使えるように構成しているので、ぜひご自身のプロジェクトにも応用してみてください。

コード一式(HTML・CSS・JavaScript)

以下に、今回解説する「サムネイル付き自作スライダー」のコード一式を掲載します。HTML・CSS・JavaScriptの3つのコードを組み合わせることで、画像とテキストが連動し、サムネイルからも操作可能な動的スライダーが簡単に構築できます。

また、カスタマイズしやすくするために、コードには細かくコメントを記載しています。レイアウトや動作を自分のサイトに合わせて調整したい方にも活用しやすい内容になっています。

スワン
スワン

「コードが少し長く感じるかも…」という方も大丈夫です!このあと、仕組みや重要なポイントを解説していきますので、気になるところから読み進めてみてください。

HTMLコード

スライダー全体の構造を定義するHTMLコードです。画像・テキストのスライド本体に加え、ナビゲーションやサムネイル表示の枠組みも含まれています。

<!-- スライダー全体のラッパー -->
<div class="slider">
  <!-- スライダーの表示領域 -->
  <div class="slider-viewport">
    <!-- トラック全体の幅を設定 -->
    <div class="slider-track-wrapper">
      <!-- トラック全体の初期位置を設定 -->
      <div class="slider-track-offset">
        <!-- トラック位置の調整やスライドのアニメーションを補助 -->
        <div class="slider-track">
          <!-- スライド要素を横並びに配置し、実際に移動する部分 -->
          <div class="slider-items">
            <!-- 各スライド -->
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo01.webp" alt="ヴェネツィアの静かな運河沿いの街並みと停泊するボート、左手にはカフェでくつろぐ人々の姿"></div>
              <p class="slider-text">カラフルな建物が並ぶヴェネツィアの運河沿い。<br class="only-pc">穏やかな水面にはボートが浮かび、静かな日常が流れている。</p>
            </div>
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo02.webp" alt="独立型バスタブと天然素材の洗面台が調和する高級感あるモダンなバスルーム"></div>
              <p class="slider-text">木と石の質感が美しく調和したモダンなバスルーム。<br class="only-pc">独立型バスタブが空間にゆとりをもたらし、上質な時間を演出します。</p>
            </div>
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo03.webp" alt="大きなガラス窓から自然光が差し込む、観葉植物とカラフルな椅子が並ぶモダンなロビー"></div>
              <p class="slider-text">ガラス越しに街の景色を望むロビー。<br class="only-pc">自然光が心地よく差し込み、カラフルなインテリアとグリーンが調和した開放的な空間。</p>
            </div>
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo04.webp" alt="紅葉した街路樹とレンガの建物が並ぶ静かな住宅街の通り"></div>
              <p class="slider-text">秋の光がやさしく差し込む静かな通り。<br class="only-pc">色づいた街路樹とレンガの建物が調和し、心和む風景をつくり出しています。</p>
            </div>
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo05.webp" alt="黄色いソファと丸いクッションが置かれた日当たりの良いリビングルーム"></div>
              <p class="slider-text">ビタミンカラーのソファと差し込む陽射しが心地よい、開放感あふれるリビング。<br class="only-pc">リラックスと明るさが共存する空間。</p>
            </div>
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo06.webp" alt="ネイビーブルーのソファとアート作品が飾られたリビングの壁面"></div>
              <p class="slider-text">壁一面を彩るアートと、深みのあるブルーのソファが映えるこだわりのリビング空間。<br class="only-pc">小物使いにも個性が光ります。</p>
            </div>
            <div class="slider-slide">
              <div class="slider-image"><img src="images/slider_photo07.webp" alt="木製の本棚と観葉植物、レザーソファが置かれた落ち着いたインテリア空間"></div>
              <p class="slider-text">木の温もりが感じられる本棚に、グリーンとレザーのアクセント。<br class="only-pc">読書やくつろぎの時間を心地よく演出するコーナー。</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <!-- 矢印ナビゲーションとサムネイルをまとめたスライダー操作エリア -->
  <div class="slider-controls">
    <div class="slider-controls-inner">
      <!-- 矢印ナビゲーション(前後のスライドに移動) -->
      <div class="slider-nav">
        <button type="button" aria-label="前のスライドへ"></button>
        <button type="button" aria-label="次のスライドへ"></button>
      </div>
      <!-- サムネイルエリアの表示領域 -->
      <div class="slider-thumbnails">
        <!-- 中央のサムネイル1つ分の表示枠 -->
        <div class="slider-thumbnails-center">
          <!-- トラック全体の幅を設定 -->
          <div class="slider-thumbnails-track-wrapper">
            <!-- トラック全体の初期位置を設定 -->
            <div class="slider-thumbnails-track-offset">
              <!-- トラック位置の調整やスライドのアニメーションを補助 -->
              <div class="slider-thumbnails-track">
                <!-- サムネイル要素を横並びに配置し、実際に移動する部分 -->
                <div class="slider-thumbnails-items"></div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

CSSコード

スライダーのスタイルやレイアウトを定義するCSSコードです。画像サイズの比率指定やレスポンシブ対応、ホバーエフェクトなども含まれています。

/* ======================== */
/* レスポンシブ対応:モバイル向け調整 */
/* ======================== */
/* PC専用の改行タグを非表示にする */
@media screen and (max-width: 767px) {
  br.only-pc {
    display: none;
  }
}

/* ======================== */
/* スライダー全体のスタイル */
/* ======================== */
.slider {
  position: relative;
  overflow: hidden;
  visibility: hidden;
}

/* スライダーの表示領域 */
.slider-viewport {
  cursor: grab;
  -webkit-user-select: none;
          user-select: none;
}

/* スライドを横並びに配置 */
.slider-items {
  display: flex;
  pointer-events: none;
}

/* 各スライドを均等幅に設定 */
.slider-slide {
  flex: 1;
}

/* ======================== */
/* スライド内の画像のスタイル */
/* ======================== */
/* スライド内の画像を比率固定で表示 */
.slider-image {
  position: relative;
  aspect-ratio: 1480/832;
}
.slider-image img {
  display: block;
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ======================== */
/* スライドテキストのスタイル */
/* ======================== */
.slider-text {
  line-height: 1.8;
}
@media print, (min-width: 768px) {
  .slider-text {
    margin-top: 20px;
    font-size: 16px;
    text-align: center;
  }
}
@media screen and (max-width: 767px) {
  .slider-text {
    margin-top: 15px;
    font-size: 15px;
  }
}

/* ======================== */
/* スライド操作エリアのスタイル(ナビゲーション+サムネイル) */
/* ======================== */
@media print, (min-width: 768px) {
  .slider-controls {
    margin-top: 20px;
  }
}
@media screen and (max-width: 767px) {
  .slider-controls {
    margin-top: 15px;
  }
}

.slider-controls-inner {
  position: relative;
}

/* ======================== */
/* 矢印ナビゲーションのスタイル */
/* ======================== */
/* 矢印ボタンのサイズ調整 */
.slider-nav button {
  display: block;
  position: absolute;
  top: 0;
  height: 100%;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  line-height: 1;
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
}
@media print, (min-width: 768px) {
  .slider-nav button {
    width: 40px;
  }
}
@media screen and (max-width: 767px) {
  .slider-nav button {
    width: 25px;
  }
}

/* 矢印アイコンのスタイル */
.slider-nav button::before {
  content: '';
  display: block;
  position: absolute;
  top: 50%;
  background: #c11a51;
  -webkit-mask: url(../images/slider_arrow.svg) 0 0/contain no-repeat;
          mask: url(../images/slider_arrow.svg) 0 0/contain no-repeat;
  transition: opacity 300ms cubic-bezier(.215, .61, .355, 1);
}
@media print, (min-width: 768px) {
  .slider-nav button::before {
    width: 25px;
    height: 20px;
    margin-top: -10px;
  }
}
@media screen and (max-width: 767px) {
  .slider-nav button::before {
    width: 19px;
    height: 15px;
    margin-top: -7.5px;
  }
}

/* 矢印ボタンのホバー・アクティブ状態のスタイル */
@media print, (min-width: 768px) {
  .slider-nav button:hover::before,
  .slider-nav button:active::before {
    opacity: 0.5;
  }
}

/* 左矢印ボタンのスタイル */
.slider-nav button:nth-child(1) {
  left: 0;
}
.slider-nav button:nth-child(1)::before {
  left: 0;
  transform: scaleX(-1);
}

/* 右矢印ボタンのスタイル */
.slider-nav button:nth-child(2) {
  right: 0;
}
.slider-nav button:nth-child(2)::before {
  right: 0;
}

/* ======================== */
/* サムネイルのスタイル */
/* ======================== */
.slider-thumbnails {
  overflow: hidden;
}
@media print, (min-width: 768px) {
  .slider-thumbnails {
    width: 670px;
    margin: 0 auto;
  }
}
@media screen and (max-width: 767px) {
  .slider-thumbnails {
    margin: 0 25px;
  }
}

/* 中央のサムネイル1つ分の表示枠 */
.slider-thumbnails-center {
  width: 20%;
  margin: 0 auto;
}

/* サムネイルを横並びに配置 */
.slider-thumbnails-items {
  display: flex;
}

/* 各サムネイルを均等幅に設定 */
.slider-thumbnails-slide {
  flex: 1;
}
@media print, (min-width: 768px) {
  .slider-thumbnails-slide {
    padding: 0 5px;
  }
}
@media screen and (max-width: 767px) {
  .slider-thumbnails-slide {
    padding: 0 3px;
  }
}

/* サムネイルのボタンスタイル */
.slider-thumbnails-slide button {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: 1480/832;
  margin: 0;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  line-height: 1;
  cursor: pointer;
  -webkit-appearance: none;
          appearance: none;
  transition: opacity 300ms cubic-bezier(.215, .61, .355, 1);
}

.slider-thumbnails-slide button::before {
  content: "";
  display: block;
  position: absolute;
  left: 0;
  top: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border: 2px solid #c11a51;
  opacity: 0;
  transition: opacity 300ms cubic-bezier(.215, .61, .355, 1);
}

.slider-thumbnails-slide button img {
  display: block;
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* サムネイルのホバー・アクティブ状態 */
@media print, (min-width: 768px) {
  .slider-thumbnails-slide button:hover,
  .slider-thumbnails-slide button:active {
    opacity: 0.5;
  }
}

/* 現在のスライドを示すサムネイルのスタイル */
.slider-thumbnails-slide.is-current button {
  pointer-events: none;
}
.slider-thumbnails-slide.is-current button::before {
  opacity: 1;
}

/* ======================== */
/* グラブカーソルの設定 */
/* ======================== */
/* フリック操作中のカーソル変更 */
.slider.is-grabbing .slider-viewport,
.slider.is-grabbing .slider-viewport * {
  cursor: grabbing !important;
}

/* ======================== */
/* サムネイルが5個以下のときのレイアウト調整 */
/* ======================== */
.slider.is-lte5 .slider-controls {
  display: flex;
  justify-content: center;
}
@media print, (min-width: 768px) {
  .slider.is-lte5 .slider-nav button:nth-child(1) {
    left: -35px;
  }
}
@media print, (min-width: 768px) {
  .slider.is-lte5 .slider-nav button:nth-child(2) {
    right: -35px;
  }
}
@media print, (min-width: 768px) {
  .slider.is-lte5 .slider-thumbnails {
    width: auto;
  }
}
.slider.is-lte5 .slider-thumbnails-center {
  width: auto;
}
.slider.is-lte5 .slider-thumbnails-track-wrapper {
  width: auto !important;
}
.slider.is-lte5 .slider-thumbnails-track-offset {
  transform: none !important;
}
.slider.is-lte5 .slider-thumbnails-track {
  transform: none !important;
}
.slider.is-lte5 .slider-thumbnails-items {
  transform: none !important;
}
.slider.is-lte5 .slider-thumbnails-slide {
  flex: none;
}
@media print, (min-width: 768px) {
  .slider.is-lte5 .slider-thumbnails-slide {
    width: 124px;
  }
}
@media screen and (max-width: 767px) {
  .slider.is-lte5 .slider-thumbnails-slide {
    width: 53px;
  }
}

JavaScriptコード

スライダーの動作(自動スライド、フリック、ナビゲーション、サムネイル連動など)を実現するJavaScriptコードです。Intersection Observer API を活用し、スライダーが画面内に入ったタイミングで自動再生が始まる仕組みも組み込んでいます。

Intersection Observer の基本を詳しく知りたい方はこちらの記事もおすすめです!

あわせて読みたい
Intersection Observer 入門|スクロールに応じた処理をデモで直感的に理解しよう!
Intersection Observer 入門|スクロールに応じた処理をデモで直感的に理解しよう!
(($) => {

  $('.slider').each((_, sliderElement) => {
    const $slider = $(sliderElement);

    /**
    * スライダーのプロパティを格納するオブジェクト
    */
    const props = {
      autoSlideInterval: 4000, // 自動スライドの間隔(ミリ秒単位)
      autoSlideTimer: 0, // 自動スライド用のタイマーID(clearTimeout用)
      current: 0, // 現在表示されているスライドのインデックス
      isAnimating: false, // スライドアニメーションが実行中かどうかのフラグ
      isFlickSlide: false, // フリック動作によるスライドが有効かどうかのフラグ
      isFlickTouch: false, // フリック動作が進行中かどうかを判定するフラグ
      isStart: false, // スライダーがビューポート内で動作を開始したかどうか
      length: 0, // スライドの総数(クローンを含まないオリジナルの数)
      slideDuration: 500, // スライドアニメーションの所要時間(ミリ秒単位)
      slideEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)' // スライドアニメーションのイージング(CSSの値形式)
    };

    /**
    * スライダーの初期化処理を行う関数
    */
    function init() {
      // スライド数を取得
      props.length = $slider.find('.slider-slide').length;

      // 各スライドとそれに対応するサムネイルのHTMLをそれぞれ配列に格納
      let slideArray = [];
      let thumbArray = [];
      for (let i = 0; i < props.length; i++) {
        // スライド本体のHTMLを取得して保存
        slideArray.push($slider.find('.slider-slide:nth-child(' + (i + 1) + ')').prop('outerHTML'));

        // 対応するサムネイルのHTMLを作成して保存(スライド画像を再利用)
        const imgSrc = $slider.find('.slider-slide:nth-child(' + (i + 1) + ') .slider-image img').attr('src');
        thumbArray.push('<div class="slider-thumbnails-slide" data-num="' + i + '"><button type="button" aria-label="スライド' + (i + 1) + 'に移動"><img src="' + imgSrc + '" alt=""></button></div>');
      }

      // ループ再生のため、先頭と末尾にクローンスライドとクローンサムネイルを追加
      let slideFirstHtml = []; // 先頭に追加するためのスライド
      let thumbFirstHtml = []; // 先頭に追加するためのサムネイル
      let slideLastHtml = []; // 末尾に追加するためのスライド
      let thumbLastHtml = []; // 末尾に追加するためのサムネイル

      for (let i = 0; i < 4; i++) {
        // 末尾の4件を取得
        const firstIndex = (props.length * 2 + i - 4) % props.length;
        slideFirstHtml.push(slideArray[firstIndex]);
        thumbFirstHtml.push(thumbArray[firstIndex]);

        // 先頭の4件取得
        const lastIndex = i % props.length;
        slideLastHtml.push(slideArray[lastIndex]);
        thumbLastHtml.push(thumbArray[lastIndex]);
      }

      // クローンスライドと元スライドを結合して表示エリアに挿入
      $slider.find('.slider-items').html(slideFirstHtml.join('') + slideArray.join('') + slideLastHtml.join(''));

      // サムネイル数によって処理を分岐(6個以上でスライド式、5個以下なら固定表示)
      if (props.length > 5) {
        $slider.find('.slider-thumbnails-items').html(thumbFirstHtml.join('') + thumbArray.join('') + thumbLastHtml.join(''));
      } else {
        // 5個以下はループなし・固定レイアウト用クラスを付与
        $slider.find('.slider-thumbnails-items').html(thumbArray.join(''));
        $slider.addClass('is-lte5');
      }

      // 最初のサムネイルにアクティブクラスを付与
      $slider.find('.slider-thumbnails-slide[data-num="0"]').addClass('is-current');

      // トラック全体の幅(クローンを含めた幅)を設定し、初期位置を調整
      $slider.find('.slider-track-wrapper, .slider-thumbnails-track-wrapper').width((props.length + 8) * 100 + '%');
      $slider.find('.slider-track-offset, .slider-thumbnails-track-offset').css({ transform: 'translateX(' + -4 / (props.length + 8) * 100 + '%)'});

      // スライダーを初期表示する
      $slider.css({ visibility: 'visible' });

      // イベントの登録処理を呼び出し
      setupEvents();

      // ビューポート内に入ったときにスライダーを開始
      setupIntersectionObserver();
    }

    /**
    * 各種イベントを登録する関数
    */
    function setupEvents() {
      // 矢印ボタンのクリックイベントを登録
      $slider.find('.slider-nav > button').on('click', (e) => {
        if (!props.isAnimating) {
          const idx = $slider.find('.slider-nav > button').index($(e.currentTarget));
          const direction = idx === 0 ? -1 : 1; // 左: -1, 右: 1
          moveSlide(direction, 0); // 指定方向にスライドを移動
        }
        return false; // クリックイベントの伝播を停止
      });
    
      // サムネイルのクリックで対応するスライドに移動
      // ※ 6枚以上ある場合はクローン分を考慮して +4 の補正が必要
      $slider.find('.slider-thumbnails-slide > button').on('click', (e) => {
        if (!props.isAnimating) {
          const idx = $slider.find('.slider-thumbnails-slide').index($(e.currentTarget).parent());
          const direction = (props.length > 5) ? idx - (props.current + 4) : idx - props.current; // クローンを含む表示インデックスとの差分
          moveSlide(direction, 0); // 指定方向にスライドを移動
        }
        return false; // クリックイベントの伝播を停止
      });
    
      // フリック操作の開始イベントを登録
      $slider.find('.slider-viewport').on('mousedown touchstart', (e) => {
        if (!props.isAnimating) {
          handleFlick(e); // フリック操作の処理を開始
        }
      });
    }

    /**
    * ビューポートにスライダーが入ったときに自動スライドを開始するための監視を設定
    */
    function setupIntersectionObserver() {
      // Intersection Observerのインスタンスを作成
      const observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          // 要素がビューポート内に入ったら、自動スライドを開始
          if (entry.isIntersecting && !props.isStart) {
            props.isStart = true; // スライダーがビューポート内に入ったことを記録
            startTimer(); // 自動スライドを開始
          }
        });
      }, {
        root: null, // ビューポートを基準に監視
        rootMargin: '0px', // マージンなし
        threshold: 0 // 要素が1ピクセルでも見えたらトリガー
      });

      // スライダーの監視を開始
      observer.observe($slider[0]);
    }

    /**
    * スライダーを指定方向に移動させる
    * @param {number} direction - スライドの方向(右: 1, 左: -1, その場: 0)
    * @param {number} dragOffsetX - ドラッグ操作時のオフセット値(ピクセル単位)
    */
    function moveSlide(direction, dragOffsetX) {
      // アニメーション中はスライドの操作を無効化
      props.isAnimating = true;

      // 次に表示するスライドのインデックスを計算
      let next = props.current + direction;
      if (next >= props.length) next -= props.length; // 最後のスライドから最初に戻る
      if (next < 0) next += props.length; // 最初のスライドから最後に戻る

      // 現在のサムネイルを更新
      $slider.find('.slider-thumbnails-slide.is-current').removeClass('is-current');
      $slider.find('.slider-thumbnails-slide[data-num="' + next + '"]').addClass('is-current');

      // サムネイル1枚ぶんの幅を取得してアニメーションを設定
      const slideW = $slider.find('.slider-thumbnails-slide').outerWidth();
      $slider.find('.slider-thumbnails-items')[0].animate([
        { transform: 'translateX(0px)' },
        { transform: 'translateX(' + (-slideW * direction) + 'px)' } // 指定方向に移動
      ], {
        duration: props.slideDuration, // アニメーションの時間
        easing: props.slideEasing, // イージング関数
      });

      // スライドの幅を取得してアニメーションを設定
      const areaW = $slider.outerWidth();
      $slider.find('.slider-items')[0].animate([
        { transform: 'translateX(' + dragOffsetX + 'px)' }, // ドラッグ位置から開始
        { transform: 'translateX(' + (-areaW * direction) + 'px)' } // 指定方向に移動
      ], {
        duration: props.slideDuration, // アニメーションの時間
        easing: props.slideEasing, // イージング関数
      }).onfinish = () => {
        // アニメーション完了後にトラック位置を調整
        $slider.find('.slider-track, .slider-thumbnails-track').css({
          transform: 'translateX(' + (-next / (props.length + 8) * 100) + '%)'
        });
      
        // 一時的なドラッグ位置をリセット
        $slider.find('.slider-items, .slider-thumbnails-items').css({
          transform: 'translateX(0px)'
        });
        
        // 状態を更新
        props.current = next;
        props.isAnimating = false;

        // 自動スライドタイマーを再開
        startTimer();
      };
    }

    /**
    * フリック操作の処理を行う関数
    * @param {Event} e - 発生したイベントオブジェクト
    */
    function handleFlick(e) {
      clearTimeout(props.autoSlideTimer); // 自動スライドのタイマーを停止
      props.isFlickSlide = false; // フリック操作によるスライドの状態をリセット
      props.isFlickTouch = true; // フリック操作中であることをフラグに設定

      // イベントタイプに応じた名前を設定
      const moveEvent = (e.type === 'touchstart') ? 'touchmove.flick' : 'mousemove.flick';
      const endEvent = (e.type === 'touchstart') ? 'touchend.flick' : 'mouseup.flick mouseleave.flick';
      const origStart = (e.type === 'touchstart') ? e.originalEvent.changedTouches[0] : e.originalEvent;

      // フリックの初期位置を記録
      const touchPosition = {
        preX: origStart.pageX,
        preY: origStart.pageY
      };

      $slider.addClass('is-grabbing'); // グラブカーソルを表示

      // フリック中の動作を処理
      $slider.find('.slider-viewport').on(moveEvent, (e) => {
        if (props.isFlickTouch) {
          const origMove = (e.type === 'touchmove') ? e.originalEvent.changedTouches[0] : e.originalEvent;
          touchPosition.curX = origMove.pageX;
          touchPosition.curY = origMove.pageY;
          touchPosition.diffX = touchPosition.curX - touchPosition.preX;
          touchPosition.diffY = touchPosition.curY - touchPosition.preY;
    
          // フリック操作が水平方向かどうかを判定
          if (Math.abs(touchPosition.diffY) < Math.max(Math.abs(touchPosition.diffX), 10) || e.type === 'mousemove') {
            props.isFlickSlide = true; // フリック操作をスライドとして認識
            e.preventDefault(); // デフォルト動作をキャンセル

            // スライドをドラッグする動きを再現
            $slider.find('.slider-items').css({
              transform: 'translateX(' + touchPosition.diffX + 'px)'
            });
          } else {
            // 垂直方向の動きや不正な操作は無効化
            props.isFlickSlide = false;
            props.isFlickTouch = false;
            $slider.find('.slider-viewport').off('.flick').removeClass('is-grabbing'); // イベントを解除
            startTimer(); // 自動スライドを再開
          }
        }
      });
      
      // フリック終了時の処理を登録
      $slider.find('.slider-viewport').on(endEvent, (e) => {
        if (props.isFlickTouch) {
          props.isFlickTouch = false; // フリック操作終了をフラグに設定
          $slider.find('.slider-viewport').off('.flick'); // イベントを解除
          $slider.removeClass('is-grabbing');

          if (props.isFlickSlide) {
            const areaW = $slider.outerWidth(); // スライダーの幅を取得

            // フリック距離に応じてスライドを移動
            if (touchPosition.diffX > areaW / 10) {
              moveSlide(-1, touchPosition.diffX); // 左方向にスライド
              e.preventDefault();
            } else if (touchPosition.diffX < -areaW / 10) {
              moveSlide(1, touchPosition.diffX); // 右方向にスライド
              e.preventDefault();
            } else {
              moveSlide(0, touchPosition.diffX); // 元の位置に戻る
              e.preventDefault();
            }
          } else {
            // フリックとして認識されない場合はタイマーを再開
            startTimer();
          }
        }
      });
    }

    /**
    * 自動スライドのタイマーを開始する関数
    */
    function startTimer() {
      // 既存のタイマーがあればクリア
      clearTimeout(props.autoSlideTimer);

      // タイマーを設定して一定時間後にスライドを移動
      props.autoSlideTimer = setTimeout(() => {
        if (!props.isAnimating) {
          moveSlide(1, 0);  // 次のスライドに移動
        }
      }, props.autoSlideInterval); // 指定された間隔で実行
    }

    init(); // 初期化実行
  });

})(jQuery);

コードのポイント解説

今回のスライダーも、JavaScript を中心に動作する構成となっています。ここでは、実装の中で特に押さえておきたい仕組みや工夫を、ピックアップして解説していきます。

なお、スライダーの基本構造やイベント処理、自動スライドの考え方などは、以下の記事で詳しく解説しています。内容が共通する部分も多いため、必要に応じてあわせてご覧ください。

あわせて読みたい
自作スライダー完全解説!jQueryを活用してスライダーを実装する方法とメリット
自作スライダー完全解説!jQueryを活用してスライダーを実装する方法とメリット

ポイント1:スライダーを初期化する仕組み

スライダーの動作を正しく行うためには、最初に各種要素の準備と配置を行う初期化処理が必要です。このセクションでは、init() 関数の中で行っている主な処理を4つの観点から解説していきます。

1. スライドとサムネイルのHTMLを取得して配列に格納

まず、.slider-slide クラスを持つ各スライドのHTMLと、それに対応するサムネイル画像のHTMLを、それぞれ配列に格納します。サムネイルには、スライドに使用している画像をそのまま再利用しています。

let slideArray = [];
let thumbArray = [];
for (let i = 0; i < props.length; i++) {
  slideArray.push(...); // 各スライドのHTML
  thumbArray.push(...); // 対応するサムネイルHTML
}

このようにしておくことで、後続の処理でループ用のクローンを動的に生成できるようになります。

2. クローンスライドとクローンサムネイルの生成・配置

ループ再生に対応するために、先頭と末尾にクローンを4件ずつ追加します。クローンのスライドとサムネイルは、それぞれ元の配列の内容から切り出して作成されます。

for (let i = 0; i < 4; i++) {
  // 末尾の4件(先頭に追加)
  // 先頭の4件(末尾に追加)
}

こうして生成されたクローンとオリジナルをつなぎ合わせて、.slider-items および .slider-thumbnails-items に挿入しています。

なぜ4件ずつ?

今回のサムネイルの設計上、動作中に空白ができないようにするために、前後に4件ずつのクローンを追加する必要があります。

3. サムネイルの挙動をスライド数に応じて分岐

サムネイルの表示方法は、スライド数によって挙動が変わるよう設計されています。

  • 6個以上の場合: クローン付きのスライド式サムネイルを表示。中央に現在のスライドが来るように動きます。
  • 5個以下の場合: クローンを使わず固定表示し、現在のスライドに対応したサムネイルだけがスタイルで区別されます。
if (props.length > 5) {
  // クローンサムネイルを含めてスライド式に表示
} else {
  // 固定表示のクラスを付与
  $slider.addClass('is-lte5');
}

スライド数が少ない場合は表示領域に収まる形でスライドせずに一覧表示することで、より直感的でわかりやすいUIを実現しています。

4. トラックの幅と初期位置の調整

スライダー本体とサムネイルのどちらも、先頭と末尾のクローンを含めた横幅になるようにトラックの幅を設定し、オリジナルの先頭スライドが中央にくる位置へ移動させています。

$slider.find('.slider-track-wrapper, .slider-thumbnails-track-wrapper').width((props.length + 8) * 100 + '%');
$slider.find('.slider-track-offset, .slider-thumbnails-track-offset').css({ transform: 'translateX(' + -4 / (props.length + 8) * 100 + '%)'});

この -4 という数値は、先頭に追加されたクローン要素が4件あることに対応しています。そして 8 は、先頭と末尾それぞれに4件ずつ、計8件のクローンが存在するため、スライダー全体のスライド数(元スライド + クローン)を表しています。

  • width((props.length + 8) * 100 + '%')
    スライダー全体の幅を「全スライド数(元 + クローン)分の横幅」として設定
  • translateX(-4 / (props.length + 8) * 100 + '%')
    先頭に追加されたクローン4件分を考慮して、実際に表示したい1枚目(元スライドの先頭)がちょうど中央に来るよう調整

スライダー本体とサムネイルの両方でこのロジックを共有することで、表示のズレが起きず、同期が正確に保たれるように設計されています。

みなと
みなと

この widthtransform の設定は、スライダー本体には常に適用されますが、サムネイル部分については 6件以上ある場合のみ実際に反映されます。5件以下の場合は .is-lte5 クラスによって無効化され、サムネイルはスライドせず固定表示されます。

ポイント2:スライドを動かすロジック

スライドの切り替え処理は moveSlide 関数にまとめられており、矢印やサムネイルのクリック、フリック操作、自動スライドなど、あらゆる操作から共通で呼び出される中核的な処理です。

この関数では、スライド本体とサムネイルを同時に動かす処理が実装されており、それぞれが連動して切り替わる仕様となっています。

1. 次に表示するスライドの決定

スライドはループ仕様となっているため、端まで到達してもスムーズに次のスライドへ進めるように制御されています。

let next = props.current + direction;
if (next >= props.length) next -= props.length;
if (next < 0) next += props.length;
  • props.current は現在表示中のスライド番号
  • direction は移動方向(1 = 進む、-1 = 戻る)

このように、移動先がスライド数の範囲を超えないように、ループ対応のインデックス補正を行っています。

2. アクティブなサムネイルの切り替え

次に表示されるスライドが決まったら、それに対応するサムネイルにも is-current クラスを付与して、現在位置を示します。

$slider.find('.slider-thumbnails-slide.is-current').removeClass('is-current');
$slider.find('.slider-thumbnails-slide[data-num="' + next + '"]').addClass('is-current');

これにより、スライドの切り替えと連動して、対応するサムネイルが強調表示されるようになります。

3. サムネイルのアニメーション(6個以上の場合)

スライド数が6個以上ある場合、サムネイルも横スクロールで移動します。

const slideW = $slider.find('.slider-thumbnails-slide').outerWidth();
$slider.find('.slider-thumbnails-items')[0].animate([
  { transform: 'translateX(0px)' },
  { transform: 'translateX(' + (-slideW * direction) + 'px)' }
], {
  duration: props.slideDuration,
  easing: props.slideEasing,
});
  • サムネイル1枚分の幅 × 方向ぶん移動することで、「次のスライドが中央に来る」動きが再現されます。

なお、スライド数が5個以下の場合はスライドせず、現在位置のサムネイルがスタイルで切り替わるだけになります。

みなと
みなと

JSの処理ではサムネイル数にかかわらずスライドを実行しますが、5個以下のときは .is-lte5 によって transform が無効化され、見た目はスライドしないようになっています!

4. スライド本体のアニメーション

スライド本体も同様に、Web Animations API を使ってスムーズに左右へ移動します。

const areaW = $slider.outerWidth();
$slider.find('.slider-items')[0].animate([
  { transform: 'translateX(' + dragOffsetX + 'px)' },
  { transform: 'translateX(' + (-areaW * direction) + 'px)' }
], {
  duration: props.slideDuration,
  easing: props.slideEasing,
}).onfinish = () => {
  // 完了後の処理
};
  • dragOffsetX は、フリック操作時にずらす距離を表し、ボタンクリックなどフリック以外の場合は常に 0 になります。
  • 幅を元に計算した距離だけスライドさせ、動的な切り替えを実現します。

5. アニメーション完了後の処理

アニメーションが完了したタイミングで、スライド位置や状態のリセット処理を行います。

$slider.find('.slider-track, .slider-thumbnails-track').css({
  transform: 'translateX(' + (-next / (props.length + 8) * 100) + '%)'
});
$slider.find('.slider-items, .slider-thumbnails-items').css({
  transform: 'translateX(0px)'
});
props.current = next;
props.isAnimating = false;
startTimer();
  • .slider-track.slider-thumbnails-track には、次に表示したいスライドの位置が中央に来るよう translateX を指定
  • .slider-items.slider-thumbnails-items の方は、一時的な移動分を 0px にリセット
  • 上記2つを組み合わせることで、見た目を変えずに内部状態だけを整えることができ、次回のスライド処理がスムーズに始められます
  • props.current を更新し、アニメーション中フラグを解除して、次の操作受付状態に戻します
  • 自動スライド用のタイマーも再開します
みなと
みなと

サムネイルのスライドアニメーションのときと同様に、5個以下のときは .is-lte5 クラスによってtransformが無効化され、スライドしないようになっています。

まとめ

今回ご紹介したサムネイル付きスライダーは、画像とテキストが連動し、直感的な操作性とスムーズな動作を実現できる構成となっています。

そのまま使っても十分に実用的ですが、用途に応じて機能を追加・変更しやすい柔軟な設計になっているため、「自動スライドの挙動を変える」「ナビゲーションの表示を調整する」など、独自の拡張も自由に行えます。

ぜひプロジェクトに合わせてカスタマイズし、自分だけのスライダーに仕上げてみてください!

押していただけると励みになります!

ABOUT ME
みなと
みなと
フロントエンドエンジニア
東京のWeb制作会社で15年以上働いている現役フロントエンドエンジニアです。これまで、いろんなプロジェクトに関わりながら、フロントエンド開発やWebデザインに取り組んできました。このブログでは、今までの経験を活かして、Web制作に役立つ情報やノウハウをシェアしていきたいと思います。初心者の方から、現場で働く方まで、誰でも参考にできる内容をお届けしますので、ぜひ覗いてみてください。
記事URLをコピーしました