みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Next.jsでページのスクロールを管理する 📄

スクロールに連動してアニメーションを動かすサイトや、パララックスを実装する際、

  1. ページのスクロール量(一番上から何%スクロールしているか)
  2. スクロールの方向

を管理すると、非常に楽に実装することができます。
今回はNext.js(React.js)のHooks APIを使ってステートでスクロール量、方向を管理するモックを作ってみました。

TypeScript

import { useEffect, useState } from 'react';
import styled, { css } from 'styled-components';

// https://blog.kimizuka.org/entry/2021/01/12/234900
import Animation from '../assets/js/Animation';

let colors = [
  '#E0F2F1',
  '#B2DFDB',
  '#80CBC4',
  '#4DB6AC',
  '#26A69A',
  '#009688',
  '#00897B',
  '#00796B',
  '#00695C',
  '#004D40',
  '#E0F2F1'
];

const Wrapper = styled.div`
  position: relative;
  transition: opacity .2s ease-in-out;

  .list {
    position: relative;
    height: ${ 100 * colors.length }vh;

    li {
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 64px;
      font-weight: bold;

      &:last-child {
        &:before {
          content: '0' !important;
        }
      }
    }

    ${(() => {
      let styles = '';

      for (let i = 0; i < colors.length; ++i) {
        styles += `
          li:nth-child(${ i + 1 }) {
            height: ${ 100 / colors.length }%;
            background: ${ colors[i] };

            &:before {
              color: ${ i < 7 || 9 < i ? '#000' : '#fff' };
              content: '${ i === 10 ? 0 : i }';
            }
          }
        `;
      }

      return css`${ styles }`;
    })()}
  }

  .debug {
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    top: 0; right: 0;
    width: 80px; height: 80px;
    color: #fff;
    font-size: 10px;
    background: rgba(0, 0, 0, .4);
  }

  .btns {
    position: fixed;
    top: 0; left: 0;
    font-size: 10px;

    li {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 44px; height: 44px;
      cursor: pointer;
      background: rgba(0, 0, 0, .4);

      + li {
        margin-top: 2px;
      }
    }
  }
`;

export default function ScrollWrapper() {
  const [ direction, setDirection ] = useState('');
  const [ progress, setProgress ] = useState(0);
  const [ scrollProgress, setScrollProgress ] = useState(0);
  const [ lastProgress, setLastProgress ] = useState(0);
  const [ contentsHeight, setContentsHeight ] = useState(0);
  const [ windowHeight, setWindowHeight ] = useState(0);
  const [ scrollY, setScrollY ] = useState(0);
  const [ animation, setAnimation ] = useState(new Animation({
    startValue: 0,
    targetValue: 0,
    duration: 1
  }));

  useEffect(() => {
    if (!direction) {
      init();

      return;
    }

    warp();
  }, [direction]);

  useEffect(() => {
    const diff = lastProgress - progress;

    if (diff < 0) {
      if (Math.abs(diff) > .99) {
        setDirection('up');
      } else {
        setDirection('down');
      }
    } else {
      if (Math.abs(diff) > .99) {
        setDirection('down');
      } else {
        setDirection('up');
      }
    }

    warp();
    setLastProgress(progress);
  }, [progress]);

  useEffect(() => {
    window.scrollTo(window.scrollX, (contentsHeight - windowHeight) * scrollProgress);
  }, [scrollProgress]);

  useEffect(() => {
    if (contentsHeight - windowHeight) {
      setProgress(scrollY / (contentsHeight - windowHeight));
    }
  }, [scrollY]);

  useEffect(() => {
    window.addEventListener('mousedown', cancelScroll, {
      passive: false
    });
    window.addEventListener('wheel', cancelScroll, {
      passive: false
    });
    window.addEventListener('touchstart', cancelScroll, {
      passive: false
    });

    return () => {
      animation.stop();
      window.removeEventListener('mousedown', cancelScroll);
      window.removeEventListener('wheel', cancelScroll);
      window.removeEventListener('touchstart', cancelScroll);
    }

    function cancelScroll() {
      animation.stop();
    }
  }, [animation]);

  function init(): void {
    window.addEventListener('resize', handleResize, {
      passive: true
    });

    document.addEventListener('scroll', handleScroll, {
      passive: true
    });

    handleResize();
    setScrollProgress(normalize(0));
  }

  function warp(): void {
    if (1 <= progress && direction === 'down') {
      window.scrollTo(window.scrollX, 1);
    } else if (progress <= 0 && direction === 'up') {
      window.scrollTo(window.scrollX, contentsHeight - windowHeight - 1);
    }
  }

  function handleResize(): void {
    setContentsHeight(document.getElementById('app').clientHeight);
    setWindowHeight(window.innerHeight);
  }

  function handleScroll(): void {
    setScrollY(window.scrollY);
  }

  function handleClickBtn(targetProgress: number): void {
    const startProgress = progress;
    const isReverse = Math.abs(targetProgress - startProgress) > .5;
    const diff = targetProgress - startProgress;

    setAnimation(
      new Animation({
        startValue: 0,
        targetValue: targetProgress,
        duration: 800,
        easing: 'easeInOut',
        step: isReverse ? (val: number) => {
          const direction = diff < 0 ? 1 : -1;

          val = startProgress + direction * ((1 - Math.abs(diff)) * val);

          if (val < 0) {
            val = 1 + val;
          } else if (1 < val) {
            val = val - 1;
          }

          setScrollProgress(normalize(val));
        } : (val: number) => {
          val = startProgress + diff * val;

          setScrollProgress(normalize(val));
        }
      })
    );
  }

  function normalize(val: number): number {
    return Math.max(.0002, Math.min(val, .9999));
  }

  return (
    <Wrapper className={ !!direction ? '' : 'transparent' }>
      <ol className="list"> {
        colors.map((_, i) => {
          return (
            <li key={ i } />
          );
        })
      } </ol>
      <div className="debug">
        <div>
          <p>{ (progress * 100).toFixed(2) }</p>
          <p>{ direction }</p>
        </div>
      </div>
      <ol className="btns">
        {
          colors.map((_, i, arr) => {
            if (i === arr.length - 1) {
              return;
            }

            const progress = i / (arr.length - 1);

            return (
              <li
                key={ i }
                onClick={ () => handleClickBtn(progress) }
              >{ (progress * 100).toFixed(2) }</li>
            );
          })
        }
        <li onClick={ () => handleClickBtn(1) }>100</li>
      </ol>
    </Wrapper>
  );
}


DEMO

https://kimizuka.github.io/next-scroll/scroll/


ページの最下部と最上部をつなげることで無限スクロールを実現していますが、そこは好みです。
iOSの実機で見ると、最下部から最上部に戻る際に一瞬引っ掛かりを感じましたが、PC版Chromeで観るといい感じに慣性スクロールも引き継いでくれます。

追記

blog.kimizuka.org

カスタムフックにしました!