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

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

App Router(Next.js 13.4)を使ったプロジェクトで書き出した静的サイトのページ遷移をシームレスにすべく View Transitions API を試したり、Nested Layoutsを試したりする 💻


View Transitions API とは

まずは、Google Chromeでこちらをご覧ください。

http203-playlist.netlify.app

Chrome Developers で紹介されている View Transitions API が体験できるサイトです。

developer.chrome.com

リンクをクリックするとURLが切り替わっているにも関わらず、シームレスに画面が遷移しています。

このような動作を実現するには、WebサイトをSPA(Single Page Application)で作りつつ、pushState を上手く使うなどの方法がありましたが、 View Transitions API を使えばもっと手軽に実装できるようです。

欠点としては、現在はChrome、Edgeのみの対応(Operaでも一部動作するらしいが未検証)で、Safari、つまりiOSデバイスで動作しないことです。
(上記の デモサイト を見ればわかるのですが、Androidには対応しています)

日本でのiPhoneのシェアを考えると、実践に導入できるのはSafariに対応してからになるとは思うのですが、こういう遷移をさせたいというリクエストがめちゃくちゃくることを見越して、Next.jsの静的書き出しとのコンビネーションを検証しておこうと思います。
今後を見越しての検証なので、App Routeを使用します。静的書き出しで検証しているのは、普段静的書き出しを使うことが多いからです。

Next.js(App Router) + View Transitions API

DEMO

next-view-transitions-api.netlify.app

カードをクリックするとページが遷移するのですが、その際シームレスにアニメーションしてからURLが切り替わります。
検証のためにさささっと実装したので、ブラウザの「戻る」「進む」を使った遷移には未対応です。

リポジトリ

github.com

ざっくり解説

Chrome Developer に記載されている通り、document.startViewTransition を使って実装しました。

document.startViewTransitionにはページ遷移時のDOMの動きを指定する関数を渡しておきます。

そして、遷移時にstartViewTransitionが呼び出されると、

  1. ページの現在の状態をキャプチャ
  2. startViewTransitionに渡されたコールバック関数を実行(DOMを変更)
  3. 遷移先のページをキャプチャ
  4. 遷移先のページに、古いキャプチャと新しいキャプチャを重ねて配置
  5. 古いキャプチャと新しいキャプチャをクロスフェードで切り替える

という動作をするようです。
詳細は Chrome DeveloperのHow these transitions work を確認してください。

developer.chrome.com

ざっくりとした実装方法

解説の通り、startViewTransitionにDOMの動きを指定する関数を渡します。

document.startViewTransition(() => {
  // ページ遷移時に実行したいアニメーションの処理を書く
});

ただし前述の通り、startViewTransitionはまだ全てのブラウザに対応しているわけではないので、

if (document.startViewTransition) {
  document.startViewTransition(() => {
    // ページ遷移時に実行したいアニメーションの処理を書く
  });
}

という形で、startViewTransitionの存在を確認してから実行する必要があります。

Next.jsを使っている場合、

(document as any).startViewTransition(() => {
  setIsTransition(true);
  router.push('/new-page-path');
});

という形で、ステートの変化でDOMさせつつ、新しいページに遷移させるのが簡単な実装になるかと思います。

export function Card({
  onClick = function() {},
  isTransition = false
}: {
  onClick?: () => void;
  isTransition?: boolean;
}) {
  return (
    <Wrapper
      className="card"
      data-is-transition={ String(isTransition) }
      onClick={ onClick }
    >
      <div className="inner">
        <img
          width={ 300 }
          height={ 169 }
          src={ '/images/counterattack-of-the-timer.jpg' }
        />
        <div className="body">
          <h2>タイトル</h2>
          <h3>サブタイトル</h3>
          <p>ディスクリプション</p>
        </div>
      </div>
    </Wrapper>
  );
}
.card {
  width: 300px; height: 300px;
  transition: transform .2s ease-in-out;
  cursor: pointer;

  p {
    opacity: 0;
    transition: opacity .2s ease-in-out;
  }

  &[data-is-select='true'] {
    height: 400px;
    transform: scale(1.2);

    p {
      opacity: 1;
    }
  }
}

というような形で、ステートをカスタムデータ属性に渡し、CSSアニメーションでDOMをアニメーションさせるのがお手軽かと思いました。

注意事項

ブラウザの「戻る」「進む」を使って遷移する時は、startViewTransitionが発火しないため別途設定が必要です。
他にももろもろ設定できるのですが、今回は簡単な検証にとどめておきます。

Next.js(App Router) + Nested Layouts

今後を見越した検証も大事なのですが、いますぐSafariにも対応させる方法はないかと考えた結果、getLayoutパターン(App RouterではNested Layouts)を使えば良いのではないかと思い検証してみました。

getLayoutとは

詳しくは こちらの記事 をご覧ください。

blog.kimizuka.org

ざっくりいうと、ページを遷移しても、アンマウントされないコンポーネントです。
ページのほぼ全てをLayout内に記述すれば、実質SPAのようなものになるのではないかと考えました。

blog.kimizuka.org

Nested Layoutsとは

詳しくは ドキュメント をご覧ください。

nextjs.org

ざっくりいうと、App Routerで使えるgetLayoutのようなものです。
getLayoutはPages Routerでしか使えないため、どうしたものかとドキュメントを検索したときに見つけました。
Migrating the getLayout() pattern to Layouts と紹介されています。

nextjs.org

DEMO

next-view-transition.netlify.app

ページ遷移時に、View Transitions API で実装したデモサイトとほぼ同じ動きをします。(厳密にいうとクロスフェードしてません)
ブラウザの「戻る」「進む」にも対応済みです。
しかも、Safariでも問題なく動作するので、iPhoneでも観覧できます。

リポジトリ

github.com

ざっくりとした実装方法

解説の通り、Nesting Layoutsを使い、ほぼ全ての部分をRootLayout内に置いています。

src/app/layout.tsx(抜粋)
export default function RootLayout({
  children
}: {
  children: ReactNode
}) {
  return (
    <html lang="ja">
      <body>
        <AllPageLayout>{ children }</AllPageLayout>
      </body>
    </html>
  )
}

AllPageLayoutという名前が適切なのかはおいておき、サイトのほぼ全ての部分がこちらのコンポーネントに収納されています。

AllPageLayout.tsx(抜粋)
export function AllPageLayout({ children }: {
  children: ReactNode
}) {
  const [ isSelect, setIsSelect ] = useState(false);
  const router = useRouter();

  function handleClickCard() {
    router.push('/articles/counterattack-of-the-timer');
  }

  return (
    <Wrapper>
      <main>
        <Card
          onClick={ handleClickCard }
          isSelect={ isSelect }
        />
      </main>
      <div>{ children }</div>
    </Wrapper>
  );
}

const Wrapper = styled.div`
  > main {
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    top: 0; bottom: 0;
    left: 0; right: 0;
  }
`;

なので、実質SPAのような形になり、シームレスなページ遷移に見えるわけです。
静的サイトとして書き出したあとも、 next/routernext/link を使った遷移は内部でいい感じに pushState を実行してくれるので、SPAのように振る舞えます。
実際のところは、ページをリロードした際にアニメーションが見えないように、あれこれ設定しているのですが、今回は触れません。