💡

[Next.js, GSAP] ページ遷移アニメーションをGSAPで実装した話

に公開

この記事は

Next.jsのページ遷移アニメーションをGSAPで実装したのでまとめました。

実装するアニメーションは

  • 既存のページのコンテンツが上にフェードアウト
  • 遷移先のコンテンツが下からフェードイン

既存ページのフェードアウトをなかなか見かけないので自分でやってみることに。
↓ポートフォリオで試してみました。

https://youtube.com/shorts/-iZ8WgXMYso?si=R7TM-v9868MaAzPL

なんでGSAP?

最初は自力実装しようと思ったのですが、知識不足もありuseStateとuseEffectだらけになり、わけわからん〜〜〜!!状態。
断念。

アニメーションはこだわりたいのでmotion.jsを使ってみましたが、
ページを離れるときのアニメーションがうまく動かず。別の方法を探ることに。
issueや海外フォーラムなども覗いてみたのですが解決せず。

静的サイト実装時によくお世話になっている
GSAPを採用することにしました。

app/layout.tsx

app/page名/page.tsxを受け取る準備。
childrenには各ページのpage.tsxが入るだけなので割愛。

<body>
  <PageTransition>{children}</PageTransition>
</body>

ナビゲーション

全文載せると長いので、一部抜粋。
大事なところだけ。

aタグ部分はnext/linkでやります。
が、そのまま飛ばすのではなく、
e.preventDefaultで動き自体はキャンセル、goTo()でsetLeavingTo()を実行。

setLeavingTo()は後述。
ReactのuseContextを使ってstateを渡しています。いったん飛ばして読んでね。

「buttonタグでも良くね?」と思われるかもですが、HTMLのセマンティクス的にはaタグが望ましいです(←最初はbuttonで実装した🤔)

const { setLeavingTo } = useSharedState();

const goTo = (target: string) => {
  setLeavingTo(target);
};

const links = [
  { href: "/", label: "HOME" },
  { href: "/about", label: "ABOUT" },
  { href: "/works", label: "WORKS" },
];

{ links.map((link) => (
  <Link
    href={link.href}
    onClick={(e) => {
      e.preventDefault();
      goTo(link.href);
    }}
  >
    {lin.label}
  </Link>
))}

components/PageTransition.tsx

gsapアニメーションの実装はここに書いていきます。全文載せておく。
setLeavingTo()はここでも出てきますがもうちょっと待ってください。

"use client";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { usePathname, useRouter } from "next/navigation";
import { useRef } from "react";
import { useSharedState } from "@/context/SharedStateProvider";

gsap.registerPlugin(useGSAP);

gsap.defaults({ overwrite: "auto" });

const PageTransition = ({ children }: { children: React.ReactNode }) => {
  const el = useRef<HTMLDivElement>(null);
  const router = useRouter();
  const pathname = usePathname();

  const { leavingTo, setLeavingTo } = useSharedState();

  useGSAP(
    () => {
      if (leavingTo) {
        gsap.killTweensOf(el.current);
        
        gsap.to(el.current, {
          opacity: 0,
          y: -50,
          duration: 1.6,
          ease: "power3.in",
          onComplete: () => {
            const to = leavingTo;
            setLeavingTo(null);
            router.push(to);
          },
        });
      } else {
        gsap.fromTo(
          el.current,
          { opacity: 0, y: 30 },
          {
            opacity: 1,
            y: 0,
            duration: 1.6,
            ease: "power3.out",
            delay: 0.2,
          }
        );
      }
    },
    { dependencies: [leavingTo], scope: el }
  );

  return (
    <div
      ref={el}
    >
      {children}
    </div>
  );
};

export default PageTransition;

useGSAP

useEffect的な使い方なので、Reactやってる人はすぐに使えるはず。優秀すぎる...。

gsap.defaults({ overwrite: "auto" });
gsap.killTweensOf(el.current);

ボタン連打対策。アニメーションをいい感じにしてくれます。
基本的にはgsap.defaults、
明示的に制御したい場面はkillTweensOf。

アニメーションが被ってしまう現象がありましてその対策で入れてます。

onComplete: () => {
  const to = leavingTo;
  setLeavingTo(null);
  router.push(to);
},
{ dependencies: [leavingTo], scope: el }
  • dependencies 依存関係を渡す
  • scope スコープを作ります

厳密には全然違うのだろうけれど、useEffectとuseRef的な。

SharedStateProvider.tsx

「メニューとアニメーションのコンポーネント両方で同じstateを使いたい」

というわけでReactのuseContextを使って「どのページにいるのか」を伝達しています。
公式チュートリアルがわかりやすいです。
https://ja.react.dev/reference/react/useContext

"use client";

import React, { createContext, useContext, useState } from "react";

type SharedStateContextType = {
  leavingTo: string | null;
  setLeavingTo: (id: string | null) => void;
};

const defaultValue: SharedStateContextType = {
  leavingTo: null,
  setLeavingTo: () => {},
};

const SharedStateContext = createContext<SharedStateContextType>(defaultValue);

export const SharedStateProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [leavingTo, setLeavingTo] = useState<string | null>(null);

  return (
    <SharedStateContext.Provider
      value={{ leavingTo, setLeavingTo }}
    >
      {children}
    </SharedStateContext.Provider>
  );
};

export const useSharedState = () => {
  const context = useContext(SharedStateContext);
  if (!context)
    throw new Error("useSharedState must be used within SharedStateProvider");
  return context;
};

まとめ

motion.jsで実装できた人いたら教えてださいませ。結局、exitが動かない理由がわからなかった。

結局、GSAPのおかげでページ遷移時のあれこれを考えずに実装できてしまった。

Next.jsもそうですが、便利なエコシステムで固められてきて裏側の実装がわからなくなったというのはなんとなく感じてます。
数年前にReactを触った時はNext.jsがまだなくて、Reduxとかで頑張ってた。
まぁ僕はまだ語れる領域にいないので考えたら負けなんですけども。。。

Typescriptに関してはchatGPTにかなり助けれてます。なんて便利になったんだ...!!

Discussion