スクロールを邪魔する「スクロールスナップ」のデモコード(JavaScript)
いったん下にスクロールすると、上に戻りにくくなる、戻ろうとすると押し返されるバグを見かけたので、どのようなコードで実現できるか Gemini と相談してデモ用のコードを作った。
スクロールスナップで実現できるようで、 最近ではcssで実現するらしい。
Scroll snap (スクロールスナップ) - MDN Web Docs 用語集 | MDN
https://developer.mozilla.org/ja/docs/Glossary/Scroll_snap
CSS スクロールスナップ - CSS | MDN
https://developer.mozilla.org/ja/docs/Web/CSS/Guides/Scroll_snap
ここでは、JavaScriptで作ってみた。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ロック/非ロック混在 スナップバックデモ</title>
<style>
body { margin: 0; padding: 0; }
/* スクロールコンテナは相対配置にし、絶対配置の子要素の基準とする */
.scroll-container {
height: 100vh;
width: 100vw;
overflow-y: scroll;
scroll-behavior: smooth;
position: relative; /* 破線コンテナの基準 */
}
.section {
width: 100%; display: flex; flex-direction: column;
justify-content: center; align-items: center; text-align: center;
font-size: 1.5em; padding: 20px; box-sizing: border-box;
border-bottom: 5px dashed #ccc;
}
.section[data-lock="true"] {
border: 5px solid red;
background-color: #fff8e1;
}
.section[data-lock="false"] {
background-color: #e0f0ff;
}
.section:nth-child(1) { background-color: #ffe0e0; }
.section:nth-child(5) { background-color: #e0ffe0; }
h1 { font-size: 2em; margin-bottom: 0.5em; }
#lockHeight{height: 40px; width: 60px; font-size: 36px; text-align: center;}
#section1{height: 60vh;}
#section2{height: 60vh;}
#section3{height: 60vh;}
#section4{height: 60vh;}
#section5{height: 120vh;}
/* === 仮想セクションの破線とインジケーターのスタイル === */
#dashLinesContainer {
position: absolute; /* .scroll-containerを基準に配置 */
top: 0;
left: 0;
width: 100%;
/* スクロールバーよりも手前に表示 */
z-index: 10;
pointer-events: none; /* マウスイベントを貫通させる */
/* height: 100%; */
}
/* 破線コンテナのラッパー (仮想セクションの区切り全体) */
.dash-line-wrapper {
position: absolute;
width: 100%;
height: 5px; /* 線の太さ */
display: none; /* 初期は非表示 */
}
/* 破線 */
.dash-line {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 5px;
border-top: 5px dashed #3498db;
box-sizing: border-box;
}
/* ロックインジケーター */
.lock-indicator {
position: absolute;
top: -10px; /* 破線から少し上に配置 */
left: 5px; /* 左端から少し離す */
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
font-weight: bold;
color: white;
border-radius: 3px;
font-size: 0.8em;
}
.lock-indicator[data-locked="true"] {
background-color: red;
}
.lock-indicator[data-locked="false"] {
background-color: green;
}
</style>
</head>
<body>
<div class="scroll-container" id="scrollContainer">
<div id="dashLinesContainer"></div>
<section class="section" data-index="0" data-lock="false" id="section1">
<h1>セクション 1:【非ロック】</h1>
<p>このセクションはロックされていません。スクロールは邪魔されません。</p>
<div>ロック位置変更:<input type="text" id="lockHeight">(各セクションの高さは0.6)</div>
</section>
<section class="section" data-index="1" data-lock="true" id="section2">
<h1>セクション 2:【ロック中】</h1>
<p>このセクションの下からは戻りにくくなります。</p>
<p>あるいは上のセクションに移りにくくなっています。</p>
</section>
<section class="section" data-index="2" data-lock="true" id="section3">
<h1>セクション 3:【ロック中】</h1>
<p>このセクションの下からは戻りにくくなります。</p>
<p>あるいは上のセクションに移りにくくなっています。</p>
</section>
<section class="section" data-index="3" data-lock="false" id="section4">
<h1>セクション 4:【非ロック】</h1>
<p>このセクションはロックされていません。</p>
</section>
<section class="section" data-index="4" data-lock="false" id="section5">
<h1>セクション 5:【非ロック】</h1>
<p>このセクションはロックされていません。</p>
</section>
</div>
<script>
const container = document.getElementById('scrollContainer');
const sections = document.querySelectorAll('.section');
const lockHeightInput = document.getElementById('lockHeight');
const dashLinesContainer = document.getElementById('dashLinesContainer');
let lastScrollTop = 0;
// 破線要素とインジケーターの生成
const MAX_VIRTUAL_SECTIONS = 10;
let dashLineWrappers = []; // 破線とインジケーターを含むラッパー
for (let i = 0; i < MAX_VIRTUAL_SECTIONS; i++) {
const wrapper = document.createElement('div');
wrapper.classList.add('dash-line-wrapper');
const line = document.createElement('div');
line.classList.add('dash-line');
wrapper.appendChild(line);
const indicator = document.createElement('div');
indicator.classList.add('lock-indicator');
indicator.textContent = '?';
wrapper.appendChild(indicator);
dashLinesContainer.appendChild(wrapper);
dashLineWrappers.push({ wrapper, indicator });
}
// -------------------------------------------------------------
// 【共通関数化】現在のスクロール状態とロック情報に関する計算をすべて行う
// -------------------------------------------------------------
const getScrollInfo = () => {
const currentScrollTop = container.scrollTop;
const lockValue = parseFloat(lockHeightInput.value);
const validLockValue = isNaN(lockValue) || lockValue <= 0 ? 0 : lockValue; // 0以下も無効
const sectionHeight = container.clientHeight * validLockValue;
// 現在のセクションインデックスを計算
const currentSectionIndex = (sectionHeight > 0) ? Math.round(currentScrollTop / sectionHeight) : 0;
const targetScrollTop = currentSectionIndex * sectionHeight;
return {
currentScrollTop,
sectionHeight,
currentSectionIndex,
targetScrollTop,
validLockValue
};
};
// -------------------------------------------------------------
// 共通関数: 現在の目標セクションがロックされているか判定する
// -------------------------------------------------------------
const isCurrentSectionLocked = () => {
const info = getScrollInfo();
if (info.sectionHeight === 0) {
return false;
}
// ロック判定は、計算されたインデックスに対応するHTMLセクションのdata-lock属性に基づく
const currentSection = sections[info.currentSectionIndex ]; //セクションの上にロックライン
// const currentSection = sections[info.currentSectionIndex -1 ]; //セクションの下にロックライン
return currentSection && currentSection.getAttribute('data-lock') === 'true';
};
// -------------------------------------------------------------
// 破線要素とインジケーターの位置/状態を更新する関数
// -------------------------------------------------------------
const updateDashLines = () => {
const info = getScrollInfo();
const { sectionHeight, validLockValue } = info;
const scrollHeight = container.scrollHeight;
// 【削除】dashLinesContainerの高さ調整やtransformは不要
if (sectionHeight > 0 && validLockValue > 0) {
dashLineWrappers.forEach((item, i) => {
const lineIndex = i + 1;
const lineTop = lineIndex * sectionHeight;
const nextSectionIndex = lineIndex; //セクションの上にロックライン
// const nextSectionIndex = lineIndex -1; //セクションの下にロックライン
const nextSection = sections[nextSectionIndex];
const isLocked = nextSection ? (nextSection.getAttribute('data-lock') === 'true') : false;
if (lineTop > 0 && lineTop < scrollHeight) {
// lineTopがコンテンツ内での絶対位置
item.wrapper.style.top = `${lineTop}px`;
item.wrapper.style.display = 'block';
// インジケーターを更新
item.indicator.setAttribute('data-locked', isLocked ? 'true' : 'false');
item.indicator.textContent = isLocked ? 'L' : 'U'; // L: Locked, U: Unlocked
} else {
// 表示範囲外の破線は非表示
item.wrapper.style.display = 'none';
}
});
} else {
// sectionHeightが0または無効な場合はすべての破線を非表示
dashLineWrappers.forEach(item => {
item.wrapper.style.display = 'none';
});
}
};
// -------------------------------------------------------------
// 1. スクロールイベント監視 (コアとなるスナップバックロジック)
// -------------------------------------------------------------
container.addEventListener('scroll', function() {
const info = getScrollInfo();
// 【削除】transformによる追従処理
// 破線の位置とロックインジケーターの状態を更新
updateDashLines();
// スクロール方向の判定
const isScrollingUp = info.currentScrollTop < lastScrollTop;
lastScrollTop = info.currentScrollTop;
if (!isCurrentSectionLocked()) {
return;
}
// *** コアロジック: 上スクロール(下からの場合)でのみスナップバック ***
// 上方向へのスクロールの時、かつ目標位置よりも上にスクロールしようとした場合のみ、目標位置に戻す。
if (isScrollingUp && info.currentScrollTop < info.targetScrollTop) {
container.scrollTop = info.targetScrollTop;
}
});
// 2. マウスホイールイベント監視 (ブロック制御)
// ロックされているセクションの境界で上方向へのホイール操作をブロックする
container.addEventListener('wheel', function(e) {
if (!isCurrentSectionLocked()) {
return;
}
const info = getScrollInfo();
const scrollingUp = e.deltaY < 0;
const currentSectionTop = info.targetScrollTop;
if (scrollingUp && info.currentScrollTop <= currentSectionTop) {
e.preventDefault();
container.scrollTop = currentSectionTop;
}
}, { passive: false });
// 3. キーボードイベント監視 (ブロック制御)
container.addEventListener('keydown', function(e) {
if (!isCurrentSectionLocked()) {
return;
}
const info = getScrollInfo();
const currentSectionTop = info.targetScrollTop;
if (e.key === 'ArrowUp' || e.key === 'PageUp') {
if (info.currentScrollTop <= currentSectionTop) {
e.preventDefault();
container.scrollTop = currentSectionTop;
}
}
});
// 入力値が変更されたときにも破線の更新を行う
lockHeightInput.addEventListener('input', updateDashLines);
window.addEventListener('load', () => {
if (!lockHeightInput.value) {
lockHeightInput.value = '0.6';
}
// スタイルとロック機能の初期設定が完了してから破線を更新
updateDashLines();
container.dispatchEvent(new Event('scroll'));
});
</script>
</body>
</html>
コメント
コメントを投稿