BLOG

ブログ

【Nuxt3】ページ遷移アニメーションの実装について(View Transitions API編)

こちらは、Mavs Advent Calendar2023の19日目の記事です!🧁🧁

ハコザキです。

本記事は、Nuxt3でページ遷移アニメーションを実装する記事の後編です。 前編をまだ読んでいない場合はぜひ前編から読んでいただければと思います..!!
【Nuxt3】ページ遷移アニメーションの実装について(CSS, JS/GSAP編)

はじめに

前編では、Nuxtでページ遷移アニメーションを実装する上で基本となる、
CSSアニメーションやGSAPを導入したJSフックを使用したページ遷移アニメーションの実装例をご紹介しました。

本記事(後編)では、今年(2023年)に公開されたView Transitions APIをNuxtに導入し
ページ遷移アニメーションを実装する方法について詳しくご紹介します!!
本記事では、Nuxtを触ったことがない人でも最後まで実装を進めることができるよう、
Nuxtの環境構築から進めております!
前半部分はView Transitions APIを表現するための準備部分になってますので、
いち早く実装だけを読みたい場合は ”View Transitions APIでのページ遷移アニメーション実装”から
読んでいただければと思います!!

View Transitions APIとは

View Transitions APIとは、異なる DOM 状態間のアニメーション遷移を簡単に作成する仕組みを提供し、同時に DOM コンテンツも単一の手順で更新します。
View Transitions API – Web API | MDN

従来のようなアニメーションと全く異なるものになっており、
2023年3月に公開された機能になります。
これまで実装するのが難しかった、異なるページ間の連続的なアニメーション(異なる構成のDOMを繋げるアニメーション) をView Transitions APIを利用することで実装できるようになりました。
記事一覧 → 詳細へ遷移する際、サムネイル画像がページ遷移によって途切れることなくアニメーションされる 機能などが比較的簡単に実装できるようになりました。

Nuxtでも試験機能としてView Transitions APIが使えるようになっているので
今回は簡単な実装例についてご紹介します。


こちらの記事を参考にしております!
View Transitions API によるスムーズでシンプルな遷移 | Web Platform | Chrome for Developers
View Transitions API入門 – 連続性のある画面遷移アニメーションを実現するウェブの新技術 – ICS MEDIA


本記事で実装できること

キャプチャのように、記事一覧 → 詳細へ遷移した際、
記事に紐づいている絵文字(🐶など)が表示されたまま、
なめらかにページ遷移できるようになります。

実装準備

まずはNuxtの環境を構築します。
今回Nodeのバージョンは20で進めます。

pnpm dlx nuxi@latest init nuxt3-page-transitions
cd nuxt3-page-transitions

静的ページ実装

今回はトップページを一覧ページ、/blog/id を記事詳細とします。
各ページ共通部分にトップへ戻る用のリンクを設置するため、
app.vue、layouts/default.vueをそれぞれ以下のようにします。

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>
<template>
  <header>
    <NuxtLink to="/">Home</NuxtLink>
  </header>
  <main>
    <slot />
  </main>
</template>

続いてトップページに記事一覧用のページを実装します。
pages/index.vueを作成し以下のように記述します。

<template>
  <h1>TOP</h1>
  <div class="articles">
    <NuxtLink to="/blog/1" class="card">
      <span class="card__emoji">🐶</span>
      <span class="card__title">記事1</span>
    </NuxtLink>
    <NuxtLink to="/blog/2" class="card">
      <span class="card__emoji">🐱</span>
      <span class="card__title">記事2</span>
    </NuxtLink>
    <NuxtLink to="/blog/3" class="card">
      <span class="card__emoji">🐮</span>
      <span class="card__title">記事3</span>
    </NuxtLink>
  </div>
</template>

<style scoped>
.articles {
  margin-inline: auto;
  display: flex;
  justify-content: center;
  gap: 24px;
  max-width: 600px;
}
.card {
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 5px;
  padding: 16px;
}

.card__emoji {
  font-size: 48px;
}
.card__title {
  color: #1e293b;
  font-size: 16px;
  font-weight: bold;
}
</style>

※CSS等については細かく解説しませんが、
記事一覧とわかるような最低限のスタイルを当てています..!

記事詳細のページは、~/pages/blog/[id].vueを作成し以下のように記述します。

<script setup lang="ts">
import type { Article, Articles } from '~/types/article'

const { params } = useRoute()
const id = Number(params.id)

definePageMeta({
  // 数字のみのidを受け付ける
  validate: async (route) => {
    const id = route.params.id as string
    // id が数字で構成されているかをチェックする
    return /^\d+$/.test(id)
  },
})
</script>

<template>
  <article v-if="article" class="article">
    <div class="article__emoji">🐶</div>
    <h1 class="article__title">記事1</h1>
    <p class="article__content">記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。</p>
  </article>
  <p v-else>記事がみつかりませんでした。</p>
  <NuxtLink to="/" class="home-link">一覧に戻る</NuxtLink>
</template>

<style scoped>
.article {
  max-width: 600px;
  margin-inline: auto;
  padding: 80px 0;
}
.article__emoji {
  font-size: 400px;
  text-align: center;
}
.article__title {
  font-size: 40px;
  text-align: center;
}
.article__content {
  font-size: 16px;
}
.home-link {
  padding: 24px 0;
  display: grid;
  place-items: center;
}
</style>

definePageMetaのvalidateで、
/blog/[id]のid部分が数字以外のアクセスは弾くように設定しています。

あえて絵文字をすごく大きくしています

モック用記事一覧データ定義

続いて、記事一覧・詳細を疑似的な動的コンテンツに対応するため、
記事一覧をJSONで定義します。
記事一覧&詳細ページはこのJSONから値を取得するようにします。
~/public/articles.jsonを作成し以下のように記述します。

{
  "articles": [
    {
      "id": 1,
      "title": "記事1",
      "emoji": "🐶",
      "content": "記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。記事1の内容です。"
    },
    {
      "id": 2,
      "title": "記事2",
      "emoji": "🐱",
      "content": "記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。記事2の内容です。"
    },
    {
      "id": 3,
      "title": "記事3",
      "emoji": "🐮",
      "content": "記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。記事3の内容です。"
    }
  ]
}

また、記事一覧、詳細の型用のファイルを作成します。
~/types/article.tsを作成し以下のように記述します。

export interface Article {
  id: number
  title: string
  emoji: string
  content: string
}

export interface Articles {
  articles: Article[]
}

記事一覧&詳細の動的コンテンツ化

記事一覧ページの動的化を行います。
~/pages/index.vueのscript, template部分を以下のように記述します。

<script setup lang="ts">
import type { Articles } from '~/types/article'

const { data: articles } = useFetch('/articles.json', {
  transform: (data: Articles) => data.articles,
})
</script>

<template>
  <h1>TOP</h1>

  <div class="articles">
    <NuxtLink
      v-for="article in articles"
      :key="article.id"
      :to="`/blog/${article.id}`"
      class="card"
    >
      <span class="card__emoji">{{ article.emoji }}</span>
      <span class="card__title">{{ article.title }}</span>
    </NuxtLink>
  </div>
</template>

<style scoped>
...

script部分では、useFetchを使用してjsonから記事リストを取得します。
transformプロパティでオブジェクトからarticles の配列のみを取得しています。
template部分では、v-forを使用して記事リストから記事ごとのリンクを生成しています。

続いて、記事詳細の動的化を行います。
~/pages/blog/[id].vueを以下のように記述します。

<script setup lang="ts">
import type { Article, Articles } from '~/types/article'

const { params } = useRoute()
const id = Number(params.id)

const { data: article } = useFetch('/articles.json', {
  // idを元に記事を絞り込む
  transform: (data: Articles) => {
    return data.articles.find((article: Article) => article.id === id)
  },
})

definePageMeta({
  // 数字のみのidを受け付ける
  validate: async (route) => {
    const id = route.params.id as string
    // id が数字で構成されているかをチェックする
    return /^\d+$/.test(id)
  },
})
</script>

<template>
  <article v-if="article" class="article">
    <div class="article__emoji">{{ article.emoji }}</div>
    <h1 class="article__title">{{ article.title }}</h1>
    <p class="article__content">{{ article.content }}</p>
  </article>
  <p v-else>記事がみつかりませんでした。</p>
  <NuxtLink to="/" class="home-link">一覧に戻る</NuxtLink>
</template>

<style scoped>
...

ここまでで、ページ遷移アニメーションなしの一覧詳細が実装できました。

View Transitions APIでのページ遷移アニメーション実装

本題のNuxtにView Transitions APIを導入する方法についてご紹介します。

1. nuxt.config.tsに設定を追記

nuxt.config.tsに設定を付与することで
View Transitions API自体は使用できるようになります。

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },

  ssr: false,

  experimental: {
    viewTransition: true,
  },
})

2. CSSの定義

異なるページで紐付けたい2つの要素に対して、
CSSプロパティのview-transition-nameを指定します。
※ 同じ名前を付ける必要があります
view-transition-name – CSS: カスケーディングスタイルシート | MDN

~/pages/index.vueのスタイル部分、末尾に以下を追加します。

.card .card__emoji {
  view-transition-name: article-emoji;
}

続いて、遷移先の記事詳細
~/pages/blog/[id].vue のスタイルにも同じプロパティを当てます。

...
.article__emoji {
  font-size: 400px;
  text-align: center;
  view-transition-name: article-emoji;
}
...

今回は絵文字のスタイルに view-transition-name: article-emoji; を追加しました。

3. 記事用のstoreで選択中の記事の状態管理

続いて、Nuxt共通で記事の状態管理をして 選択中の記事の管理を行いたいと思います。
今回は状態管理ライブラリのPiniaを導入し記事の管理を行います。
以下のコマンドでインストールします。

pnpm i pinia @pinia/nuxt

nuxt.config.tsに以下を追記します。

modules: ['@pinia/nuxt'],

続いて、記事用のストアを定義します。
~/stores/article.tsを作成し以下のように記述します。

import type { Article } from '~/types/article'

export const useArticleStore = defineStore('article', {
  state: (): Article => {
    return {
      id: 0,
      title: '',
      emoji: '',
      content: '',
    }
  },
  actions: {
    setActiveArticle(article: Article) {
      this.id = article.id
      this.title = article.title
      this.emoji = article.emoji
      this.content = article.content
    },
  },
})

選択中の記事のState、選択中を更新する用のメソッドを追加しました。

最後に、一覧ページでこのストアを読み込み、
アクティブな記事を更新するようにします。
~/pages/index.vueのscript, template, style部分を以下のようにします。

<script setup lang="ts">
import type { Articles } from '~/types/article'
import { useArticleStore } from '~/stores/article'

const articleStore = useArticleStore()

const { data: articles } = useFetch('/articles.json', {
  transform: (data: Articles) => data.articles,
})
</script>

<template>
  <h1>TOP</h1>

  <div class="articles">
    <NuxtLink
      v-for="article in articles"
      :key="article.id"
      :to="`/blog/${article.id}`"
      @click="articleStore.setActiveArticle(article)"
      class="card"
      :class="{ active: articleStore.id === article.id }"
    >
      <span class="card__emoji">{{ article.emoji }}</span>
      <span class="card__title">{{ article.title }}</span>
    </NuxtLink>
  </div>
</template>

<style scoped>
...

.card.active .card__emoji {
  view-transition-name: article-emoji;
}
</style>

NuxtLinkによってページ遷移する際、選択中の記事情報を更新します。
また、activeクラスを選択中の記事のみに適用されるように変更し、
view-transition-nameもactiveのみ適用されるようにしました。

動作確認

この状態でトップページから記事カードをクリックし、記事詳細へ遷移してみます。
以下のキャプチャのように、絵文字部分がページをまたいで表示されることが確認できました!

おわり

前編・後編の2つの記事に渡って、Nuxtで実現することができる
ページ遷移アニメーションの実装方法についてご紹介しました。

本記事で紹介したView Transitions APIについては
2023年12月現在、ChromeとChromiumベースのEdgeのみ対応している状況です。
“View Transition API” | Can I use… Support tables for HTML5, CSS3, etc
※ 他ブラウザ時の挙動など工夫する必要があります。

個人的に良い表現のページ遷移アニメーションのWebサイトを見つけると
色々なページにアクセスして動きを何度も見たくなります。
Nuxtでもリッチな表現のページ遷移アニメーションは実現可能ですのでぜひ試してみてください!!

RELATED ARTICLE