スクロールを邪魔する「スクロールスナップ」のデモコード(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で作ってみた。

scrollLockDemo.html

<!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>

コメント