Next.js と TypeScript で、スクロールの途中でメニューを固定する方法

TwitterFacebookHatena

TL;DR

このページでは、スクロールの途中でメニューを固定する実装方法について解説しますね。ウェブページをスクロールしても特定のメニューだけは常に画面上部に表示され続ける機能を Next.js と TypeScript で実装します。

開発環境 バージョン
Next.js 13.4.4
TypeScript 5.0.4
Emotion 11.11.0
React 18.2.0

スクロールの途中でメニューを固定する

ウェブサイトを利用していると、特定の位置までスクロールした時点でメニューバーやヘッダーが画面上部に固定され、その後スクロールしても画面上部に表示され続けるという現象を目にすることがあるでしょう。これはユーザー体験を高めるための一つの手法であり、ユーザーがいつでもナビゲーションメニューにアクセスできるようにすることで、サイトの利用がよりスムーズになります。

では、基本的な実装方法について解説します。

import { useState, useEffect } from 'react'

// Props型定義。固定化するスクロール位置を示すthresholdを受け取る。
type Props = {
  threshold: number
}

const StickyMenu = ({ threshold }: Props) => {
  // isStickyというstateを定義。デフォルトはfalse(非固定状態)
  const [isSticky, setIsSticky] = useState(false)

  useEffect(() => {
    // スクロールイベントのハンドラー定義。
    // スクロール位置がthresholdを超えるとisStickyをtrueに設定
    const handleScroll = () => {
      const currentScrollY = window.scrollY // 現在のスクロール位置を取得
      setIsSticky(currentScrollY > threshold) // 現在位置が閾値を超えていればisStickyをtrueに
    }

    // スクロールイベントにhandleScrollを登録
    window.addEventListener('scroll', handleScroll)

    // コンポーネントアンマウント時にイベントリスナーを削除
    return () => {
      window.removeEventListener('scroll', handleScroll)
    }
  }, [threshold]) // thresholdが変更されたらuseEffectを再実行

  // isStickyがtrueならpositionをfixedにし、
  // そうでなければrelativeに設定
  return <div style={{ position: isSticky ? 'fixed' : 'relative' }}>{/* Menu content */}</div>
}

ここでは、StickyMenuというコンポーネントを作成しています。このコンポーネントはthresholdというプロッパティを受け取り、この値を基準にしてスクロール位置を判断します。

thresholdよりもスクロール位置が下だった場合、isStickytrueに設定します。そして、isStickytrueの時はメニューの位置をfixedに設定し、それ以外の時はrelativeに設定します。これにより、特定のスクロール位置を超えたらメニューが固定されるようになります。

Next.js で、「スクロールの途中でメニューを固定する」を実装

次に、このStickyMenuコンポーネントを Next.js のプロジェクトで使ってみましょう。

pages/index.tsx

import { StickyMenu } from '../components/StickyMenu' // StickyMenuコンポーネントのインポート

// IndexPageというReactコンポーネントの定義
const IndexPage = () => (
  <>
    {/* StickyMenuコンポーネントの利用。スクロール位置が200を超えた時にメニューを固定化 */}
    <StickyMenu threshold={200}>
      {/* メニューアイテムのリスト */}
      <ul>
        <li>
          <a href="#section1">Section 1</a>
        </li>
        <li>
          <a href="#section2">Section 2</a>
        </li>
      </ul>
    </StickyMenu>
  </>
)

// IndexPageコンポーネントをエクスポート
export default IndexPage

この例では、StickyMenuをインポートし、その子要素としてメニュー項目を配置しています。また、thresholdプロパティに 200 を指定していますので、200px スクロールした時点でメニューが画面上部に固定されます。

メニューのスタイルを追加

次に、メニューにスタイルを追加しましょう。ここでは Emotion を使って CSS-in-JS のスタイリングを行います。

/** @jsxImportSource @emotion/react */
// @emotion/reactとcssをインポート
import { css } from '@emotion/react'
import { useState, useEffect } from 'react' // ReactのuseState, useEffectをインポート

type Props = {
  threshold: number // thresholdプロパティを定義
}

// StickyMenuというコンポーネントの定義
const StickyMenu = ({ threshold }: Props) => {
  // isStickyというstateを定義、初期値はfalse
  const [isSticky, setIsSticky] = useState(false)

  // useEffectフックを使用。
  // スクロール位置がthresholdを超えたときにisStickyをtrueにする
  useEffect(() => {
    const handleScroll = () => {
      const currentScrollY = window.scrollY // 現在のスクロール位置を取得
      setIsSticky(currentScrollY > threshold) // スクロール位置がthresholdを超えているかチェック
    }

    // スクロールイベントリスナーを設定
    window.addEventListener('scroll', handleScroll)
    // コンポーネントアンマウント時にイベントリスナーを削除
    return () => {
      window.removeEventListener('scroll', handleScroll)
    }
  }, [threshold]) // thresholdが変更されたときにeffectを再実行

  // メニューのスタイルを定義。isStickyの値に基づいてスタイルを切り替え
  const menuStyle = css`
    position: ${isSticky ? 'fixed' : 'relative'};
    top: 0;
    width: 100%;
    background: white;
    box-shadow: ${isSticky ? '0 2px 4px rgba(0, 0, 0, 0.2)' : 'none'};
  `

  // コンポーネントをレンダリング。スタイルはmenuStyleを適用
  return <div css={menuStyle}>{/* Menu content */}</div>
}

ここでは、menuStyleという変数を用意し、その中にスタイルを定義しています。isStickytrueの時はpositionfixedにし、topを 0 にすることでメニューを画面上部に固定しています。また、isStickytrueの時だけbox-shadowを適用することで、固定されたメニューに影をつけています。

メニュー項目のスクロール先を指定

最後に、各メニュー項目がクリックされた時に該当するセクションにスクロールするようにします。

const StickyMenu = ({ threshold }: Props) => {
  // omitted...

  const handleItemClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
    event.preventDefault()
    const targetId = event.currentTarget.getAttribute('href')
    const targetElement = document.querySelector(targetId)
    if (targetElement) {
      window.scrollTo({
        top: targetElement.getBoundingClientRect().top + window.pageYOffset - 100,
        behavior: 'smooth',
      })
    }
  }

  return (
    <div css={menuStyle}>
      <ul>
        <li>
          <a href="#section1" onClick={handleItemClick}>
            Section 1
          </a>
        </li>
        <li>
          <a href="#section2" onClick={handleItemClick}>
            Section 2
          </a>
        </li>
        {/* More menu items */}
      </ul>
    </div>
  )
}

ここでは、各<a>要素のonClickイベントにhandleItemClick関数を設定しています。この関数は、クリックされた要素のhref属性を取得し、それが指す要素にスクロールします。window.scrollToメソッドを使ってスクロールを行い、topオプションに目標の要素の位置を指定しています。また、-100を追加してメニューの高さを考慮し、behaviorオプションに'smooth'を指定することでスムーズなスクロールを実現しています。

以上のように、Next.js と TypeScript を使用することで、「スクロールの途中でメニューを固定する」機能を簡単かつ効率的に実装することができます。

Next.js と TypeScript で、スクロールの途中でメニューを固定する方法