【JavaScript】モーダル内画像カルーセルの自動スクロールを停止して手動操作に限定する実装完全ガイド

商品一覧では画像カルーセルの自動循環が便利でも、モーダルに持ち込むと「勝手に切り替わる」体験になり、読ませたい情報が飛ばされがちです。本記事では、一覧は自動、モーダルは手動という住み分けを崩さず、二重 setInterval() や停止漏れを根絶する実装パターンを、プレーンJavaScriptを中心に具体コード・検証観点・UX/A11yまで込みで解説します。

目次

問題の整理と要件

  • 現状:商品一覧(productGridContainer)の画像カルーセルは自動で循環。モーダルでも同じロジックが走り、60秒ごとに画像が自動切替
  • 要件モーダルでは自動切替を停止。左右ボタン(< >)でユーザーが自由に送れる手動操作限定にしたい。

主因:インターバルの複数起動と停止漏れ

  • startModalImageSlider() を呼ぶたびに setInterval() が積み増しされ、重複起動が発生。
  • stopModalImageSlider() がモーダルを閉じる時だけ呼ばれ、modalImageIntervalnull に戻しておらず、状態が“停止済みか”判別できない

結論:モーダルは「手動専用」、自動は一覧専用に限定

もっとも安全で壊れにくい設計は、自動再生ロジックを一覧に閉じ込めることです。モーダルを開くタイミングで、必ず自動を止めるモーダル内部ではインターバルを作らない。将来「操作が止まったら自動再開したい」という要望が出ても、startModalImageSlider() を呼ぶ前に stopModalImageSlider() を挟むルールを徹底すれば安全に拡張できます。

最小の対策(受け入れ済み回答の要点)

手順実装ポイント解説
1新規開始前に必ず既存インターバルを解除
function startModalImageSlider() { stopModalImageSlider(); // 追加 modalImageInterval = setInterval(...); }
二重起動を防止。自動再開が必要になっても安全に切り替えられる。
2停止時にハンドルを必ず破棄
function stopModalImageSlider() { if (modalImageInterval) { clearInterval(modalImageInterval); modalImageInterval = null; // 重要 } }
null に戻すことで「停止済み」状態が明確になり、以降の処理が単純化。
3ユーザーが触れたら自動を止める
左右ボタンの先頭で stopModalImageSlider() を呼ぶ。
「一度でも手で送ったら自動は再開しない」という意図を確実に満たせる。
4開発者ツールで監視
setInterval 呼び出し回数/modalImageInterval の値をウォッチ。
重複起動や想定外の再開を素早く特定できる。

完成イメージ:プレーンJavaScriptでの本番向け実装

以下は「一覧は自動」「モーダルは手動のみ」という仕様を満たす、再利用性の高い最小構成です。実行コードではなく貼り付けて読めるサンプルとして提示します。

HTML

<!-- 一覧(自動カルーセルを適用) -->
<section class="productGridContainer" id="productGrid">
  <article class="product-card" data-images="img/p1-1.jpg,img/p1-2.jpg,img/p1-3.jpg">
    <img class="grid-image" src="img/p1-1.jpg" alt="商品Aの画像" />
    <button class="open-modal" type="button" aria-haspopup="dialog">詳細を見る</button>
  </article>
  <article class="product-card" data-images="img/p2-1.jpg,img/p2-2.jpg">
    <img class="grid-image" src="img/p2-1.jpg" alt="商品Bの画像" />
    <button class="open-modal" type="button" aria-haspopup="dialog">詳細を見る</button>
  </article>
</section>



商品画像
×


前後ボタンで画像を切り替えます
‹›

CSS(抜粋)

.modal { position: fixed; inset: 0; display: none; }
.modal.is-open { display: block; }
.modal__backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.6); }
.modal__dialog { position: relative; margin: 4rem auto; background: #fff; max-width: 720px; padding: 1rem; border-radius: .5rem; }
.modal__figure { position: relative; display: grid; place-items: center; min-height: 320px; }
.modal__controls { display: flex; justify-content: space-between; gap: .5rem; }
.modal-prev, .modal-next { font-size: 1.5rem; padding: .5rem 1rem; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

JavaScript(一覧は自動、モーダルは手動)

(() => {
  // === 一覧側の自動カルーセル(1本のインターバルで全カードを進める) ===
  const grid = document.getElementById('productGrid');
  let gridInterval = null;
  const GRID_INTERVAL_MS = 60000;

function startGridAuto() {
if (gridInterval) return; // 二重起動防止
gridInterval = setInterval(() => {
grid.querySelectorAll('.product-card').forEach(card => {
const imgEl = card.querySelector('.grid-image');
const list = (card.dataset.images || '').split(',').map(s => s.trim()).filter(Boolean);
if (list.length <= 1) return;
const idx = Number(card.dataset.idx || '0');
const next = (idx + 1) % list.length;
card.dataset.idx = String(next);
imgEl.src = list[next];
});
}, GRID_INTERVAL_MS);
}
function stopGridAuto() {
if (!gridInterval) return;
clearInterval(gridInterval);
gridInterval = null;
}

// === モーダル側(手動専用) ===
const modal = document.getElementById('imageModal');
const modalImg = modal.querySelector('.modal-image');
const btnPrev = modal.querySelector('.modal-prev');
const btnNext = modal.querySelector('.modal-next');

let modalImages = [];
let modalIndex = 0;

// 将来の拡張に備えてハンドルを保持(今回は自動を使わない)
let modalImageInterval = null;
const MODAL_INTERVAL_MS = 60000;

function startModalImageSlider(images) {
stopModalImageSlider(); // ① 既存を必ず停止
let idx = modalIndex;   // 現在位置から開始
modalImageInterval = setInterval(() => {
idx = (idx + 1) % images.length;
modalIndex = idx;
renderModalImage();
}, MODAL_INTERVAL_MS);
}

function stopModalImageSlider() {       // ② ハンドルを確実に破棄
if (modalImageInterval) {
clearInterval(modalImageInterval);
modalImageInterval = null;
}
}

function openModal(images, startIdx = 0) {
// 一覧の自動は止める(背後で動くと認知負荷が上がる)
stopGridAuto();


// モーダルは手動専用:自動スライドを確実に止める
stopModalImageSlider();

modalImages = images.slice();
modalIndex = Math.min(Math.max(0, startIdx), Math.max(0, images.length - 1));
renderModalImage();
updateControlsVisibility();
modal.setAttribute('aria-hidden', 'false');
modal.classList.add('is-open');

// フォーカス誘導(A11y)
btnNext.focus();
bindModalEvents();


}

function closeModal() {
stopModalImageSlider();
unbindModalEvents();
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
modalImages = [];
modalIndex = 0;
// 一覧の自動を必要なら再開
startGridAuto();
}

function renderModalImage() {
if (!modalImages.length) return;
modalImg.src = modalImages[modalIndex];
modalImg.alt = `画像 ${modalIndex + 1} / ${modalImages.length}`;
}

function updateControlsVisibility() {
const multi = modalImages.length > 1;
btnPrev.style.display = multi ? '' : 'none';
btnNext.style.display = multi ? '' : 'none';
}

function PreviousSubCategory() {         // ③ 手動操作で自動停止
stopModalImageSlider();
if (!modalImages.length) return;
modalIndex = (modalIndex - 1 + modalImages.length) % modalImages.length;
renderModalImage();
}

function NextSubCategory() {             // ③ 手動操作で自動停止
stopModalImageSlider();
if (!modalImages.length) return;
modalIndex = (modalIndex + 1) % modalImages.length;
renderModalImage();
}

// === イベント配線 ===
function bindModalEvents() {
modal.addEventListener('click', onModalClick);
btnPrev.addEventListener('click', PreviousSubCategory);
btnNext.addEventListener('click', NextSubCategory);
document.addEventListener('keydown', onKeydown);
}
function unbindModalEvents() {
modal.removeEventListener('click', onModalClick);
btnPrev.removeEventListener('click', PreviousSubCategory);
btnNext.removeEventListener('click', NextSubCategory);
document.removeEventListener('keydown', onKeydown);
}
function onModalClick(e) {
const target = e.target;
if (target instanceof Element && target.hasAttribute('data-close')) {
closeModal();
}
}
function onKeydown(e) {
if (modal.getAttribute('aria-hidden') === 'true') return;
if (e.key === 'Escape') closeModal();
if (e.key === 'ArrowLeft') PreviousSubCategory();
if (e.key === 'ArrowRight') NextSubCategory();
}

// 一覧の「詳細を見る」ボタンからモーダルを開く
grid.addEventListener('click', (e) => {
const btn = e.target.closest('.open-modal');
if (!btn) return;
const card = btn.closest('.product-card');
const list = (card.dataset.images || '').split(',').map(s => s.trim()).filter(Boolean);
const idx = Number(card.dataset.idx || '0');
openModal(list, isNaN(idx) ? 0 : idx);
});

// 初期化:一覧の自動開始
startGridAuto();
})();

この実装で守られること

  • モーダルでは自動が走らないopenModal() で必ず stopModalImageSlider())。
  • 左右ボタン/矢印キーで完全手動。初回操作時に自動は確実に停止
  • 一覧の自動は1本のインターバルのみで管理(起動時に二重起動ガード)。
  • モーダルを閉じたら一覧の自動を再開。ユーザーの文脈が戻る。

「停止」をより堅牢にするためのパターン

状態を1か所で管理する(単一責務)

// グローバルには「ハンドルは1つ」のルール
let modalImageInterval = null; // モーダルの自動は原則オフだが、拡張時に備えて定義だけ保持
// start/stop は必ずペアで呼ぶ。start の先頭で stop を呼ぶのが定石。

1枚しか画像がないときはナビを隠す

function updateControlsVisibility() {
  const multi = modalImages.length > 1;
  btnPrev.style.display = multi ? '' : 'none';
  btnNext.style.display = multi ? '' : 'none';
}

フォーカス制御とキーボード操作(A11y)

  • モーダルオープン時はナビゲーションボタンにフォーカス移動。
  • Esc で閉じる、/ で前後。
  • aria-modal="true"aria-hidden の切替を確実に。

デバッグの基本(開発者ツールで確認)

観点方法期待値
インターバル重複コンソールで modalImageInterval をウォッチモーダル表示中は null、意図的に自動を再有効化した場合のみ数値ハンドル
停止漏れPerformance パネルでタイマ回数を確認モーダル開閉後に不要なタイマが残らない
UI反応矢印キー/ボタン連打画像が必ず1枚ずつ進む/戻る。フリーズしない

よくあるアンチパターン

アンチパターンなぜ問題か是正策
setInterval() を複数回呼んでハンドル未保持重複で切替間隔が短縮、停止不能に陥るグローバルに1ハンドル保持+開始前に stop()
停止後に null へ戻さない「停止済みか」判定できずロジックが複雑化停止時は必ず clearInterval() & null セット
モーダルと一覧で同じタイマを共有片方の意図がもう片方に伝播してしまう領域ごとに責務分離(一覧自動/モーダル手動)

発展:一定時間の無操作で「自動を再開」したい場合

要件が変わったときのために、無操作タイムアウト型の再開パターンを示します。モーダルではなく一覧のみを再開対象にするのが無難です。

let inactivityTimer = null;
const INACTIVITY_MS = 300000; // 5分

function resetInactivityTimer() {
if (inactivityTimer) clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => {
// モーダルが閉じているときだけ自動再開
if (document.getElementById('imageModal').getAttribute('aria-hidden') === 'true') {
startGridAuto();
}
}, INACTIVITY_MS);
}
['click','keydown','pointermove','touchstart','scroll'].forEach(ev => {
document.addEventListener(ev, resetInactivityTimer, { passive: true });
});
resetInactivityTimer();

フレームワーク別メモ

React

useEffect(() => {
  // 一覧の自動
  const id = setInterval(() => advanceGrid(), 60000);
  return () => clearInterval(id); // アンマウント時に必ず停止
}, []);

const openModal = useCallback((images, idx = 0) => {
clearInterval(gridIdRef.current);  // 一覧停止
stopModalImageSlider();            // 保険で停止
setModalState({ open: true, images, idx });
}, []);

Vue

onMounted(() => { gridId.value = setInterval(advanceGrid, 60000); });
onUnmounted(() => { if (gridId.value) clearInterval(gridId.value); });

function openModal(images, idx = 0) {
stopModalImageSlider();
modal.open = true; modal.images = images; modal.idx = idx;
}

Blazor(WebAssembly)

  • IDisposable パターンで System.Timers.Timer を破棄。
  • モーダルの OnAfterRender でフォーカス制御、OnDispose でタイマ停止。

QAチェックリスト

  • モーダルを開くと自動が止まり、左右ボタンのみで遷移する。
  • モーダルを閉じると一覧の自動が再開する。
  • 画像が1枚のときに左右ボタンが非表示になる。
  • Esc/矢印キーが期待通り動作する。
  • 開く/閉じるを10回以上繰り返してもインターバルが増殖しない。

トラブル時の切り分け

  • 自動が止まらないopenModal() 先頭で stopGridAuto()stopModalImageSlider() を本当に呼んでいるか。
  • モーダル内で勝手に動く:どこかで startModalImageSlider() を呼んでいないか。呼ぶなら必ず直前に stopModalImageSlider()
  • 稀に早送りになるsetInterval() の二重起動を疑う。ハンドルは1か所に。
  • 閉じた後もCPUが高い:不要なタイマ/イベントリスナが残っていないか Performance で確認。

最終的な最小サンプル(抜粋)

let modalImageInterval = null;

function startModalImageSlider(images) {
stopModalImageSlider();                 // ① 既存を必ず停止
let idx = 0;
modalImageInterval = setInterval(() => {
idx = (idx + 1) % images.length;
document.querySelector('.modal-image').src = images[idx];
}, 60000);
}

function stopModalImageSlider() {         // ② インターバルを後腐れなく停止
if (modalImageInterval) {
clearInterval(modalImageInterval);
modalImageInterval = null;
}
}

function PreviousSubCategory() {          // ③ 手動操作で自動停止したい場合
stopModalImageSlider();
// …画像を1つ戻す処理…
}

function NextSubCategory() {              // ③ 同上
stopModalImageSlider();
// …画像を1つ進める処理…
}

実務向けの小さなコツ

  • 変数名は用途ベースmodalImageInterval は「モーダル用はこれ1本」と決めておくと保守容易。
  • タイマと状態の寿命を一致:モーダルが開いている間は gridInterval を止めておく。
  • 画像の事前読み込み:モーダル開時に次画像を new Image().src へ投入すると切替が滑らか。
  • 遅延読込との整合loading="lazy" のままでも、モーダル内はビューポート内に入るため問題なし。
  • テレメトリ:左右ボタンのクリックをイベント送信すると、自動 vs 手動 の利用状況が可視化できる。

まとめ

モーダルでは自動切替を廃し、ユーザー主導の操作に限定するだけで、読み飛ばし・認知負荷・誤操作が大幅に減ります。実装上の肝は「インターバルは常に単一」「start() の前に stop()」「停止後は null」。一覧とモーダルの責務分離を徹底すれば、拡張にも強く、予期せぬフリッカーや暴走を確実に防げます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次