商品一覧では画像カルーセルの自動循環が便利でも、モーダルに持ち込むと「勝手に切り替わる」体験になり、読ませたい情報が飛ばされがちです。本記事では、一覧は自動、モーダルは手動という住み分けを崩さず、二重 setInterval() や停止漏れを根絶する実装パターンを、プレーンJavaScriptを中心に具体コード・検証観点・UX/A11yまで込みで解説します。
問題の整理と要件
- 現状:商品一覧(
productGridContainer)の画像カルーセルは自動で循環。モーダルでも同じロジックが走り、60秒ごとに画像が自動切替。 - 要件:モーダルでは自動切替を停止。左右ボタン(
<>)でユーザーが自由に送れる手動操作限定にしたい。
主因:インターバルの複数起動と停止漏れ
startModalImageSlider()を呼ぶたびにsetInterval()が積み増しされ、重複起動が発生。stopModalImageSlider()がモーダルを閉じる時だけ呼ばれ、modalImageIntervalをnullに戻しておらず、状態が“停止済みか”判別できない。
結論:モーダルは「手動専用」、自動は一覧専用に限定
もっとも安全で壊れにくい設計は、自動再生ロジックを一覧に閉じ込めることです。モーダルを開くタイミングで、必ず自動を止める/モーダル内部ではインターバルを作らない。将来「操作が止まったら自動再開したい」という要望が出ても、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」。一覧とモーダルの責務分離を徹底すれば、拡張にも強く、予期せぬフリッカーや暴走を確実に防げます。

コメント