🤖

【解決】ReactとFramer Motionモバイルメニューでスクロールしない問題の原因と対策

に公開

はじめに

「Framer Motionで実装したモバイルメニューのリンクをクリックしても、なぜかページ内スクロールが動かない…」

本記事では、問題に直面し、解決に至った経緯を詳しく解説します。原因は、アニメーションの実行とスクロール処理のタイミングの競合でした。同様の問題で悩んでいる方の助けになれば幸いです。

発生していた問題:メニューは閉じるが、スクロールしない

まず、どのような問題が発生していたかを具体的に説明します。

Next.jsで構築したサイトのモバイル表示時、ハンバーガーメニューを開き、ページ内リンクをタップすると、メニューはスッと閉じるものの、目的のセクションまで画面がスクロールしないという現象が起きていました。

デスクトップ版のナビゲーションでは正常に動作するため、問題はモバイルメニューの開閉ロジックにあると推測しました。

原因分析:アニメーションとスクロール処理の競合

調査の結果、原因はFramer Motionmotion.divで設定された閉じるアニメーションが完了する前に、スクロール処理が実行されてしまうことによる処理の競合でした。

問題のコードはsrc/components/Header.tsxにありました。

src/components/Header.tsx(問題のコード)
const handleNavClick = (item: NavItem) => {
  // ...

  // 1. メニューを閉じる(0.3秒のアニメーションが開始)
  setIsMenuOpen(false);

  // 2. スクロール処理を実行
  // この時、まだメニューが画面上に存在しているため、スクロールが阻害される
  scrollToSection(item.id);

  // ...
};

処理の流れは以下の通りです。

  1. ユーザーがメニュー項目をタップし、handleNavClickが実行される。
  2. setIsMenuOpen(false)が呼ばれ、Framer Motionはtransition={{ duration: 0.3 }}に従って0.3秒かけてメニューを閉じるアニメーションを開始する。
  3. アニメーションの完了を待たずに、直後のscrollToSection(item.id)が実行される。
  4. この時点ではまだメニューのDOMが画面上にオーバーレイとして存在しているため、ブラウザはスクロール処理を正しく実行できず、結果として不発に終わっていました。

解決策:setTimeoutでスクロール処理を遅延させる

原因がタイミングの問題である以上、解決策は「アニメーションが終わるのを待ってからスクロール処理を実行する」ことです。

最も手軽で直感的な方法は、setTimeoutを使ってスクロール処理をアニメーションの所要時間と同じだけ遅らせることです。

src/components/Header.tsx(修正後のコード)
const handleNavClick = (item: NavItem) => {
  // ...

  if (pathname === "/") {
    // ★修正点: setTimeoutでスクロール処理を300ms遅延させる
    setTimeout(() => {
      scrollToSection(item.id);
    }, 300); // アニメーションのdurationと合わせる
    setActiveSection(item.id);
  } else {
    // ...
  }

  // メニューを閉じる処理は即時実行
  setIsMenuOpen(false);
};

この修正により、メニューを閉じるsetIsMenuOpen(false)が実行された後、300ミリ秒の猶予が生まれます。その間にメニューは画面から消え、その後にscrollToSectionが実行されるため、スクロールが阻害されることなく正常に動作するようになりました。

【発展】より堅牢な解決策:onAnimationCompleteを使う

setTimeoutは手軽ですが、アニメーションの時間をハードコーディングするため、将来的にdurationの値を変更した際にsetTimeoutの待ち時間も修正し忘れて同じようなバグを生む可能性があります。

Framer Motionは、アニメーション完了時にコールバック関数を実行するためのonAnimationCompleteという便利なAPIを提供しています。より堅牢な実装を目指すなら、こちらを使うのがおすすめです。

// onAnimationCompleteを使った実装例
<motion.div
  animate={isMenuOpen ? "open" : "closed"}
  variants={menuVariants}
  transition={{ duration: 0.3 }}
  onAnimationComplete={() => {
    // メニューが閉じたアニメーションの完了後("closed"の後)に実行
    if (!isMenuOpen && pendingScrollTarget) {
      scrollToSection(pendingScrollTarget);
      setPendingScrollTarget(null); // 実行後にクリア
    }
  }}
>
  {/* Menu content */}
</motion.div>

この方法では、状態管理が少し複雑になりますが、アニメーションとロジックの依存関係がコード上で明確になり、より信頼性の高い実装になります。

まとめ

ReactとFramer Motionを使用したモバイルメニューがスクロールしない問題についてまとめました。
同じような問題に直面している方は、アニメーションと依存するロジックの実行順序について改めて確認してみてください。

Discussion