Web production note

 【更新日 :

【CSS, JavaScript】スクロールしてエリアが切り替わる時に次のコンテンツと重ねる

Category:
css / js

JavaScriptとposition: sticky; を用いてスクロール時にエリア同士を重ねる実装のサンプルです。

2025.02.20 更新箇所
オーバーレイ演出の追加オプションを実装したパターンを追加。

position: sticky; の特性を利用しているため、親にoverflow: hidden;設定を持つ環境下では動作しません。

通常版:動作サンプル

codepen

See the Pen Overlap areas with scrolling Vanilla JS (position: sticky;) by web_corder (@web_walking_nak) on CodePen.

オーバーレイオプション追加版:動作サンプル

codepen

See the Pen Overlap areas with scrolling Vanilla JS add overlay option (position: sticky;) by web_corder (@web_walking_nak) on CodePen.

以下は最低限動作させるための設定方法です。

通常版:コード

HTML

複数エリアの繰り返しや、間に設定しない要素を挟んでも動作します。

<div class="js-scroll-overlap">
  <p>重ねるコンテンツの数だけ生成</p>
</div>
<div class="js-scroll-overlap">
  <p>重ねるコンテンツの数だけ生成</p>
</div>

<div class="relative">
  <p>重なる演出をしない場所では、position:relative;のみ設定</p>
</div>

<div class="js-scroll-overlap">
  <p>重ねるコンテンツの数だけ生成</p>
</div>
<div class="js-scroll-overlap">
  <p>重ねるコンテンツの数だけ生成</p>
</div>

設定

  • 重ねる効果を与えるエリアに class="js-scroll-overlap" を追加。
  • 重なる演出をしない場所では、position: relative; のみ設定。

仕組み的には一番最後の要素の場合(コンテンツ途中で重なる演出をしない場合は別)、position: relative; の切り替えをせず、全ての要素に class=”js-scroll-overlap” を追加しても動作しますが、その分JavaScript側で余分な処理が増えてしまうため可能な限りマークアップで切り替えることを推奨します。

レイアウトシフトが発生するマークアップの場合は上手く処理できない

コンテンツにimgタグを内包させる場合、レイアウトシフトが発生する書き方をしているとエリアの高さが正しく取得できない場合があります。
動作がおかしいと感じる時はimgタグにwidth属性とheight属性が明記されているかなど、レイアウトシフトへの対応ができているか確認してください。

レイアウトシフトの参考サイト

入れ子構造の場合

目的の挙動になるかは内包するコンテンツ次第で多少変化しますが、入れ子構造でも動作します。
<div class="js-scroll-overlap">
  <div class="js-scroll-overlap">
    <p>重ねるコンテンツの数だけ生成</p>
  </div>
  <div class="js-scroll-overlap">
    <p>重ねるコンテンツの数だけ生成</p>
  </div>

  <div class="relative">
    <p>重なる演出をしない場所では、position:relative;のみ設定</p>
  </div>
</div>

<div class="relative">
  <p>重なる演出をしない場所では、position:relative;のみ設定</p>
</div>

CSS

position: relative; のclassは各環境に合わせて必要に応じて準備してください。

.js-scroll-overlap:not(.is-disabled) {
  --sticky-offset: -1px;
  position: sticky;
  top: var(--sticky-offset);
}
.relative {
  position: relative;
}

is-disabled はキーボードフォーカス時に動作を無効化する際に付与されます。
※フォーカス移動時にフォーカスした要素と、次のエリアが重なって要素が見えなくなってしまう可能性への対応です。

JavaScript

//スクロールしてエリアが切り替わる時に次のコンテンツと重ねる
function scrollOverlap() {
  const targets = document.querySelectorAll('.js-scroll-overlap');
  if (targets.length === 0) {
    return;
  }

  let lastWinHight = window.innerHeight;

  //position: sticky;のオフセット値をCSS変数で設定
  const setStickyOffset = () => {
    targets.forEach((target) => {
      const targetHeight = target.offsetHeight;
			//ウィンドウの高さが要素の高さより大きい場合はオフセット値を-1pxに設定
      const offsetValue = lastWinHight > targetHeight ? '-1px' : `-${targetHeight - lastWinHight}px`;
      target.style.setProperty('--sticky-offset', offsetValue);
    });
  };
  setStickyOffset();

  addEventListener('resize', () => {
	  //ウィンドウの高さが変わった時のみウィンドウの高さを更新
    const winHight = window.innerHeight;
    if (lastWinHight !== winHight) {
      lastWinHight = winHight;
    }
  });

  // bodyのサイズ変更を監視して再取得
  const body = document.body;
  // ResizeObserverを作成
  const resizeObserver = new ResizeObserver((entries) => {
    for (let entry of entries) {
      setStickyOffset();
    }
  });
  // body要素を監視対象に追加
  resizeObserver.observe(body);

  /*
    キーボード操作時に無効化するクラスを付与する
     フォーカス移動時にフォーカスした要素と、
		 次のエリアが重なって要素が見えなくなってしまう可能性への対応
  */
  const toggleDisabledClass = (boolean) => {
    targets.forEach((target) => {
      target.classList.toggle('is-disabled', boolean);
    });
  };

  //キーボードフォーカス状態を管理するフラグ
  let isKeyboardFocus = false;

  //キーボードのフォーカス移動時には無効化する
  document.addEventListener('keydown', (event) => {
    if (event.key === 'Tab') {
      //Tabキーが押されたのでキーボード操作中
      isKeyboardFocus = true; 
      toggleDisabledClass(isKeyboardFocus);
    }
  });

  //マウス操作の検知で無効化を解除する
  document.addEventListener('mousedown', () => {
    if (isKeyboardFocus) {
      //マウス操作があったのでキーボードフォーカスを解除
      isKeyboardFocus = false;
      toggleDisabledClass(isKeyboardFocus);
    }
  });
}

関数の呼び出し

任意の場所で以下を実行し関数を呼び出してください。

scrollOverlap();

オーバーレイオプション追加版:コード

ベースとなる基本的な構造や留意点などは同じですので、各項目の注釈などは通常版の内容を参照してください。

HTML

複数エリアの繰り返しや、間に設定しない要素を挟んでも動作します。

<div class="js-scroll-overlap" data-is-overlay="true">
  <p>重ねるコンテンツの数だけ生成</p>
</div>
<div class="js-scroll-overlap" data-is-overlay="false">
  <p>オーバーレイ演出が不要な箇所ではdata-is-overlay属性の値をtrue以外に変更</p>
</div>
<div class="js-scroll-overlap" data-is-overlay="true">
  <p>重ねるコンテンツの数だけ生成</p>
</div>

<div class="relative">
  <p>重なる演出をしない場所では、position:relative;のみ設定</p>
</div>

<div class="js-scroll-overlap" data-is-overlay="true">
  <p>重ねるコンテンツの数だけ生成</p>
</div>
<div class="js-scroll-overlap">
  <p>重ねるコンテンツの数だけ生成</p>
</div>

設定

  • 重ねる効果を与えるエリアに class="js-scroll-overlap" を追加。
  • 重なる演出をしない場所では、position: relative; のみ設定。
  • オーバーレイ演出が必要な箇所に data-is-overlay="true" 属性を追加。

CSS

オーバーレイの背景色や、position: relative; のclassは各環境に合わせて必要に応じて調整してください。

.js-scroll-overlap {
  position: relative;
}
.js-scroll-overlap:not(.is-disabled) {
  --sticky-offset: -1px;
  --overlay-opacity: 0;
  position: sticky;
  top: var(--sticky-offset);
}
.js-scroll-overlap[data-is-overlay="true"]:not(.is-disabled)::after {
  content: "";
  opacity: var(--overlay-opacity);
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.8);
  pointer-events: none;
}
.js-scroll-overlap[data-is-overlay="true"]:not(.is-disabled).is-active::after {
  will-change: opacity;
}
.relative {
  position: relative;
}

JavaScript

//スクロールしてエリアが切り替わる時に次のコンテンツと重ねる
//(オーバーレイオプション追加ver)
function scrollOverlap() {
  const targets = document.querySelectorAll('.js-scroll-overlap');
  if (targets.length === 0) {
    return;
  }

  //windowの高さやスクロール量を取得
  let winHeight, scrollTop, scrollBottom;
  const getScrollTop = () => {
    scrollTop = window.scrollY;
    scrollBottom = scrollTop + winHeight;
  }
  const getWindowValues = () => {
    winHeight = window.innerHeight;
    getScrollTop();
  }
  getWindowValues();
  addEventListener('resize', getWindowValues);
  addEventListener('scroll', getScrollTop);


  const targetInfoMaps = new Map();

  const setOverlapInfo = () => {
    targets.forEach((target) => {
      const isOverlay = target.dataset.isOverlay;
      if(isOverlay !== 'true') {
        targetInfoMaps.set(target, {
          isOverlay,
        });
        return;
      }

      const targetStyle = target.style;
      //stickyをリセットし正確な位置を取得
      targetStyle.position = 'relative';
      const targetRect = target.getBoundingClientRect();
      const targetTop = targetRect.top + scrollTop;
      const targetHeight = targetRect.height;
      targetStyle.position = '';

      //--sticky-offsetの値を取得し位置ずれを修正
      const targetComputedStyles = getComputedStyle(target);
      const stickyOffset = parseFloat(targetComputedStyles.getPropertyValue('--sticky-offset')) * -1;
      const targetBottom = targetTop + targetHeight + stickyOffset;

      const overlayStart = winHeight > targetHeight ? targetTop : targetBottom - winHeight;
      const overlayEnd = targetBottom;

      targetInfoMaps.set(target, {
        isOverlay,
        targetTop,
        overlayStart,
        overlayEnd
      });
    });
  }


  //position: sticky;のオフセット値をCSS変数で設定
  const setStickyOffset = () => {
    targets.forEach((target) => {
      const targetHeight = target.offsetHeight;
      //ウィンドウの高さが要素の高さより大きい場合はオフセット値を-1pxに設定
      const offsetValue = winHeight > targetHeight ? '-1px' : `-${targetHeight - winHeight}px`;
      target.style.setProperty('--sticky-offset', offsetValue);
    });
  };
  setOverlapInfo();
  setStickyOffset();


  //オーバーレイさせる要素の透明度を設定
  const raf = requestAnimationFrame;
  const setTargetOpacityValue = (target) => {
    raf(() => {
      const targetInfo = targetInfoMaps.get(target);
      if (targetInfo.isOverlay !== 'true') {
        return
      }

      const overlayStart = targetInfo.overlayStart;
      const overlayEnd = targetInfo.overlayEnd;

      let opacity = 0;

      //オーバーレイ表示範囲
      if (scrollTop > overlayStart && scrollTop < overlayEnd) {
        //値を正規化(任意の範囲を、0から1の値に変換)
        opacity =  (scrollTop - overlayStart) / (overlayEnd - overlayStart);

      //オーバーレイアニメーション終了後
      } else if (scrollTop >= overlayEnd) {
        opacity = 1;

      //オーバーレイアニメーション開始前
      } else {
        opacity = 0;
      }

      target.style.setProperty('--overlay-opacity', opacity);
    });
  };

  const initializeOverlay = () => {
    targets.forEach((target) => {
      const targetInfo = targetInfoMaps.get(target);
      if (targetInfo.isOverlay !== 'true') {
        return
      }

      //エリアが画面に表示されている間だけ透明度の処理を実行
      const scrollAnime = () => {
        setTargetOpacityValue(target);
      };
      const targetClassList = target.classList;
      const observerFunc = (entries) => {
        if (entries[0].isIntersecting) {
          //画面に表示されたときはイベントに追加
          targetClassList.add('is-active');
          addEventListener('scroll', scrollAnime);
          addEventListener('resize', scrollAnime);
        } else {
          //画面外のときはイベントを削除
          targetClassList.remove('is-active');
          removeEventListener('scroll', scrollAnime);
          removeEventListener('resize', scrollAnime);
        }
      };
      const observer = new IntersectionObserver(observerFunc);
      observer.observe(target);
      setTargetOpacityValue(target);
    });
  }
  initializeOverlay();


  // bodyのサイズ変更を監視して再取得
  const body = document.body;
  // ResizeObserverを作成
  const resizeObserver = new ResizeObserver((entries) => {
    for (let entry of entries) {
      setOverlapInfo();
      setStickyOffset();
    }
    targets.forEach((target) => {
      setTargetOpacityValue(target);
    });
  });
  // body要素を監視対象に追加
  resizeObserver.observe(body);


  /*
    キーボード操作時に無効化するクラスを付与する
     フォーカス移動時にフォーカスした要素と、
     次のエリアが重なって要素が見えなくなってしまう可能性への対応
  */
  const toggleDisabledClass = (boolean) => {
    targets.forEach((target) => {
      target.classList.toggle('is-disabled', boolean);
    });
  };

  //キーボードフォーカス状態を管理するフラグ
  let isKeyboardFocus = false;

  //キーボードのフォーカス移動時には無効化する
  document.addEventListener('keydown', (event) => {
    if (event.key === 'Tab') {
      //Tabキーが押されたのでキーボード操作中
      isKeyboardFocus = true;
      toggleDisabledClass(isKeyboardFocus);
    }
  });

  //マウス操作の検知で無効化を解除する
  document.addEventListener('mousedown', () => {
    if (isKeyboardFocus) {
      //マウス操作があったのでキーボードフォーカスを解除
      isKeyboardFocus = false;
      toggleDisabledClass(isKeyboardFocus);
    }
  });
}

関数の呼び出し

任意の場所で以下を実行し関数を呼び出してください。

scrollOverlap();
目次 を閉じる