Swiperの枚数が少ない場合でもループ可能にするサンプル


概要

- `loop: true` のときだけ事前クローンして loop 条件を満たし、bullets は元枚数分にする - `loop: false` のときはクローン処理をスキップ - 実用する際、`loop: false` のときは、そもそも今回のラッパーを通さないようにした方が、不要な処理を通らずに良さそう - `destroy()` で Swiper を破棄すると、自前クローンも削除 - 複製スライドには `data-duplicated="true"` を付与

デモ

Swiper / ループ用ラッパー

Slide 1
Slide 2
Slide 3

コード

HTML

<div class="swiper-wrapper">
  <!-- ★元スライド -->
  <div class="swiper-slide">Slide 1</div>
  <div class="swiper-slide">Slide 2</div>
  <div class="swiper-slide">Slide 3</div>
</div>

<div class="swiper-button-prev"></div>
<div class="swiper-button-next"></div>

<div class="swiper-pagination"></div>

JavaScript

/********************************************************************
  * 1. 計算だけの純粋関数群(DOM / Swiper に依存しない)★テスト対象
  ********************************************************************/

/** breakpoints をマージした 1設定に正規化 */
function normalizeConfig(base, override) {
  const bp = override || {};
  const slidesPerView =
    bp.slidesPerView !== undefined ? bp.slidesPerView : base.slidesPerView;
  const slidesPerGroup =
    bp.slidesPerGroup !== undefined ? bp.slidesPerGroup : base.slidesPerGroup;
  const centeredSlides =
    bp.centeredSlides !== undefined ? bp.centeredSlides : base.centeredSlides;

  return { slidesPerView, slidesPerGroup, centeredSlides };
}

/**
  * ある1つの設定(spv/spg/centered)について
  * 「loop が破綻しにくい最低スライド数」を返す
  */
function getRequiredSlidesForConfig(config, autoVisibleEstimate) {
  const isAuto = config.slidesPerView === 'auto';

  const spvRaw = isAuto ? autoVisibleEstimate : config.slidesPerView;
  const spv = typeof spvRaw === 'number' && spvRaw > 0 ? spvRaw : 1;
  const spg = config.slidesPerGroup && config.slidesPerGroup > 0
    ? config.slidesPerGroup
    : 1;
  const centered = !!config.centeredSlides;

  // 基本条件: totalSlides >= slidesPerView + slidesPerGroup
  let min = spv + spg;

  // centeredSlides 調整
  if (centered && !isAuto) {
    // slidesPerView が数値のとき:
    //   左右に見切れを出すぶん +1 しつつ、「spv*2 + spg」くらいまで持ち上げる
    const safeCentered = spv * 2 + spg;
    min = Math.max(min + 1, safeCentered);
  } else if (centered && isAuto) {
    // auto のときは少し控えめに
    min = Math.max(min + 1, spv * 2);
  }

  // auto のときは余裕を少し盛る
  if (isAuto) {
    const safeAuto = spv + spg * 2;
    min = Math.max(min, safeAuto);
  }

  // この設定の中では slidesPerGroup で割り切れるように
  const remainder = min % spg;
  if (remainder !== 0) {
    min += spg - remainder;
  }

  return min;
}

/** base + 全 breakpoints の設定リストを返す */
function getAllConfigs(swiperOptions) {
  const base = normalizeConfig(swiperOptions, null);
  const configs = [base];

  const bps = swiperOptions.breakpoints || {};
  Object.values(bps).forEach((bp) => {
    configs.push(normalizeConfig(swiperOptions, bp));
  });

  return configs;
}

/**
  * 全 breakpoints の中で
  * 「必要枚数が最大のパターン」を取り、それより多く、
  * かつ全 slidesPerGroup で割り切れるスライド数にそろえる
  */
function getGlobalRequiredSlides(swiperOptions, autoVisibleEstimate) {
  const configs = getAllConfigs(swiperOptions);

  let maxRequired = 0;
  const groups = new Set();

  configs.forEach((cfg) => {
    const required = getRequiredSlidesForConfig(cfg, autoVisibleEstimate);
    if (required > maxRequired) maxRequired = required;

    const spg =
      cfg.slidesPerGroup && cfg.slidesPerGroup > 0
        ? cfg.slidesPerGroup
        : 1;
    groups.add(spg);
  });

  // 最大 requiredSlides 以上かつ、全 slidesPerGroup で割り切れる値に持ち上げる
  let globalRequired = maxRequired;
  groups.forEach((spg) => {
    if (spg <= 1) return;
    const remainder = globalRequired % spg;
    if (remainder !== 0) {
      globalRequired += spg - remainder;
    }
  });

  return globalRequired;
}

/** Swiper.realIndex → 「元スライド index」に変換するマッピング */
function mapRealIndexToLogical(realIndex, originalCount) {
  if (originalCount <= 0) return 0;
  return realIndex % originalCount;
}

/********************************************************************
  * 2. DOM を触る処理(テスト時はモックしてもOKな層)
  ********************************************************************/

/** bullets を元スライド数ぶん生成して返す */
function createPaginationBullets(paginationEl, count) {
  paginationEl.innerHTML = '';
  const bullets = [];

  for (let i = 0; i < count; i++) {
    const bullet = document.createElement('button');
    bullet.type = 'button';
    bullet.className = 'swiper-pagination-bullet';
    bullet.setAttribute('aria-label', `ページ ${i + 1} へ`);
    paginationEl.appendChild(bullet);
    bullets.push(bullet);
  }

  return bullets;
}

/** bullets の active を更新 */
function updatePaginationBullets(bullets, activeIndex) {
  bullets.forEach((bullet, i) => {
    bullet.classList.toggle(
      'swiper-pagination-bullet-active',
      i === activeIndex
    );
  });
}

/**
  * 「今のグループサイズ」を Swiper から取る関数
  */
function getCurrentSlidesPerGroup(swiper) {
  // v11 だと「最終的に解決された params」がここに入ってる
  const spg = swiper.params.slidesPerGroup;
  return typeof spg === 'number' && spg > 0 ? spg : 1;
}

/**
  * bullet を「ページ数ぶん」生成する
  */
function createPaginationBullets(paginationEl, count) {
  paginationEl.innerHTML = '';
  const bullets = [];

  for (let i = 0; i < count; i++) {
    const bullet = document.createElement('button');
    bullet.type = 'button';
    bullet.className = 'swiper-pagination-bullet';
    bullet.setAttribute('aria-label', `ページ ${i + 1} へ`);
    paginationEl.appendChild(bullet);
    bullets.push(bullet);
  }

  return bullets;
}

/********************************************************************
  * 3. Swiper 初期化ラッパー
  *    - loop: true のときだけ事前クローン
  *    - destroy() 時にクローンを掃除
  ********************************************************************/

function initLoopFriendlySwiper(swiperEl, baseSwiperOptions) {
  const wrapperEl = swiperEl.querySelector('.swiper-wrapper');
  const paginationEl = swiperEl.querySelector('.swiper-pagination');

  const originalSlides = Array.from(
    wrapperEl.querySelectorAll('.swiper-slide')
  );
  const originalCount = originalSlides.length;

  originalSlides.forEach((slide) => {
    slide.dataset.duplicated = 'false';
  });

  const clonedSlides = [];
  let swiper = null;
  let bullets = [];
  let currentGroupSize = 1;

  const loopRequested = baseSwiperOptions.loop === true;
  const shouldLoop = loopRequested && originalCount > 1;

  // Swiperに渡すオプション
  // pagination は全部こちらでやるので削除しておく
  const swiperOptions = {
    ...baseSwiperOptions,
    loop: shouldLoop,
    breakpoints: baseSwiperOptions.breakpoints
      ? { ...baseSwiperOptions.breakpoints }
      : undefined,
  };
  delete swiperOptions.pagination;

  const AUTO_VISIBLE_ESTIMATE = 3;

  // ★ loop:true のときだけ「事前クローン」する
  if (shouldLoop) {
    const requiredSlides = getGlobalRequiredSlides(
      swiperOptions,
      AUTO_VISIBLE_ESTIMATE
    );

    if (originalCount < requiredSlides) {
      let currentCount = originalCount;
      let i = 0;
      while (currentCount < requiredSlides) {
        const baseSlide = originalSlides[i % originalCount];
        const clone = baseSlide.cloneNode(true);
        clone.dataset.duplicated = 'true';
        wrapperEl.appendChild(clone);
        clonedSlides.push(clone);
        i++;
        currentCount++;
      }
    }
  }

  // Swiper 初期化
  swiper = new Swiper(swiperEl, swiperOptions);

  // ▼ 自前で prev/next ボタンを Swiper に紐づける
  const prevBtn = swiperEl.querySelector('.swiper-button-prev');
  const nextBtn = swiperEl.querySelector('.swiper-button-next');

  // destroy 時に removeEventListener するためにハンドラを保持
  let onPrevClick = null;
  let onNextClick = null;

  if (prevBtn) {
    onPrevClick = () => {
      if (!swiper) return;
      swiper.slidePrev();
    };
    prevBtn.addEventListener('click', onPrevClick);
  }

  if (nextBtn) {
    onNextClick = () => {
      if (!swiper) return;
      swiper.slideNext();
    };
    nextBtn.addEventListener('click', onNextClick);
  }

  // 現在の slidesPerGroup を Swiper から取得
  function getCurrentSlidesPerGroup(swiperInstance) {
    const spg = swiperInstance.params.slidesPerGroup;
    return typeof spg === 'number' && spg > 0 ? spg : 1;
  }

  // bullets を再生成(ページ数 or スライド数ぶん)
  function rebuildBullets() {
    if (!swiper) return;

    const group = getCurrentSlidesPerGroup(swiper);
    currentGroupSize = group;

    const pageCount =
      group > 1 ? Math.ceil(originalCount / group) : originalCount;

    bullets = createPaginationBullets(paginationEl, pageCount);

    bullets.forEach((bullet, pageIndex) => {
      bullet.addEventListener('click', () => {
        if (!swiper) return;
        const targetLogicalIndex = pageIndex * currentGroupSize;
        if (shouldLoop) {
          swiper.slideToLoop(targetLogicalIndex);
        } else {
          swiper.slideTo(targetLogicalIndex);
        }
      });
    });

    handleUpdateBullets();
  }

  // bullets の active を更新する
  function handleUpdateBullets() {
    if (!swiper || bullets.length === 0) return;

    let logicalIndex;

    if (shouldLoop) {
      // loop:true のときは realIndex を mod で戻す
      const realIndex = swiper.realIndex;
      logicalIndex = mapRealIndexToLogical(realIndex, originalCount);
    } else {
      // loop:false のときは activeIndex を素直に使う
      logicalIndex =
        typeof swiper.activeIndex === 'number'
          ? swiper.activeIndex
          : swiper.realIndex;
    }

    const group = currentGroupSize > 0 ? currentGroupSize : 1;

    let pageIndex;

    if (!shouldLoop && group > 1) {
      // 非ループ + ページUIのとき、最後のページだけ特別扱い
      const spvParam = swiper.params.slidesPerView;
      const isAutoSpv = spvParam === 'auto';
      const spvNumeric = isAutoSpv
        ? AUTO_VISIBLE_ESTIMATE
        : typeof spvParam === 'number' && spvParam > 0
          ? spvParam
          : 1;

      const lastStart = Math.max(originalCount - spvNumeric, 0);
      const pageCount = bullets.length;

      if (logicalIndex >= lastStart && pageCount > 0) {
        pageIndex = pageCount - 1; // 末尾ゾーンは全部「最後のページ」
      } else {
        pageIndex = Math.floor(logicalIndex / group);
      }
    } else {
      // loop:true または group=1 のときは普通に割る
      pageIndex = group > 1
        ? Math.floor(logicalIndex / group)
        : logicalIndex;
    }

    updatePaginationBullets(bullets, pageIndex);
  }

  rebuildBullets();
  swiper.on('slideChange', handleUpdateBullets);
  swiper.on('resize', rebuildBullets);
  swiper.on('breakpoint', rebuildBullets);

  // 破棄時にクローンと bullets を掃除
  function destroy() {
    if (!swiper) return;

    // ▼ 先にナビゲーションのイベントを外す
    if (prevBtn && onPrevClick) {
      prevBtn.removeEventListener('click', onPrevClick);
    }
    if (nextBtn && onNextClick) {
      nextBtn.removeEventListener('click', onNextClick);
    }

    // Swiper 本体の destroy
    swiper.destroy(true, true);
    swiper = null;

    // ループ用に自前で追加したクローンを削除
    clonedSlides.forEach((node) => {
      if (node.parentNode) node.parentNode.removeChild(node);
    });
    clonedSlides.length = 0;

    // bullets の active をリセット(-1 で全 off)
    updatePaginationBullets(bullets, -1);
  }

  return { swiper, destroy };
}

/********************************************************************
  * 4. 実際の呼び出し(プロジェクト側から使う想定)
  ********************************************************************/

(function bootstrap() {
  const swiperEl = document.getElementById('my-swiper');

  // プロジェクト側で普通に Swiper オプションを定義
  const baseSwiperOptions = {
    loop: true, // ← ここを true/false 切り替えるだけでOK
    slidesPerView: 1,
    slidesPerGroup: 1,
    centeredSlides: true,
    spaceBetween: 16,
    breakpoints: {
      768: {
        slidesPerView: 2,
        slidesPerGroup: 1,
        centeredSlides: true,
      },
      1024: {
        slidesPerView: 3,
        slidesPerGroup: 1,
        centeredSlides: false,
      },
    },
    // pagination は指定しない(全部このラッパー側でやる)
  };

  const controller = initLoopFriendlySwiper(swiperEl, baseSwiperOptions);

  // デモ用に destroy / 再初期化ボタンを付けておく
  const destroyBtn = document.getElementById('destroy-btn');
  const initBtn = document.getElementById('init-btn');

  destroyBtn.addEventListener('click', () => {
    controller.destroy();
  });

  initBtn.addEventListener('click', () => {
    controller.destroy();
    controller = initLoopFriendlySwiper(swiperEl, baseSwiperOptions);
  });
})();

CSS

body {
  font-family: system-ui, sans-serif;
  padding: 40px;
  background: #f5f5f5;
}

.swiper {
  max-width: 800px;
  margin: 0 auto 16px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
  padding-bottom: 48px;
  position: relative;
}

.swiper-slide {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 200px;
  font-size: 24px;
  font-weight: bold;
  color: #fff;
  width: 220px; /* slidesPerView: 'auto' 用の例 */
}

.swiper-slide:nth-child(1) { background: #007bff; }
.swiper-slide:nth-child(2) { background: #28a745; }
.swiper-slide:nth-child(3) { background: #ffc107; }
.swiper-slide:nth-child(4) { background: #e83e8c; }
.swiper-slide:nth-child(5) { background: #17a2b8; }
.swiper-slide:nth-child(6) { background: #6f42c1; }
.swiper-slide:nth-child(7) { background: #fd7e14; }
.swiper-slide:nth-child(8) { background: #20c997; }
.swiper-slide:nth-child(9) { background: #343a40; }

.swiper-button-prev,
.swiper-button-next {
  color: #333;
}

.swiper-pagination {
  display: flex;
  justify-content: center;
  gap: 8px;
  align-items: center;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 8px;
}

.swiper-pagination-bullet {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  background: #ccc;
  cursor: pointer;
  opacity: 0.4;
  transition: transform 0.2s, opacity 0.2s, background 0.2s;
  border: none;
}

.swiper-pagination-bullet-active {
  background: #007bff;
  opacity: 1;
  transform: scale(1.3);
}

.controls {
  max-width: 800px;
  margin: 0 auto;
  display: flex;
  gap: 8px;
}

.controls button {
  padding: 8px 16px;
}

参照