記事一覧のカードをクリックすると、カードの見出しがそのまま詳細ページの見出しの位置へ滑っていく。ネイティブアプリでは当たり前のこの動きを、このサイトにも入れました。実装そのものは数十行で終わったのですが、「動かない理由がエラーにも警告にも出ない」場面が何度もあり、その原因を突き止めるのに時間の大半を使っています。

この記事では、Next.js 16(App Router)と React の <ViewTransition> で共有要素トランジションを作り込んだ過程を、実際に測った数値と一緒に残します。同じことをやろうとしている人が、私と同じ穴に落ちずに済めば幸いです。

前提と、やりたかったこと#

環境#

項目バージョン・設定
Next.js16.3(App Router・全ルート prerender 済み)
React19.2(App Router は Next.js が同梱する React で動く)
スタイリングTailwind CSS v4 + CSS カスタムプロパティのデザイントークン
計測Playwright(Chromium)で getBoundingClientRect() を取得

ゴール#

一覧ページの記事カードには、カテゴリのバッジ・見出し・タグが並んでいます。詳細ページにも同じ 3 つがあります。この 3 つを「同じもの」としてブラウザに伝え、ページが切り替わる瞬間に位置と大きさを補間させたい、というのが出発点です。

  1. カードの見出しが詳細ページの h1 へ移動・拡大する
  2. カテゴリとタグのバッジも、それぞれ対応する位置へ移動する
  3. それ以外の部分は全体のクロスフェードで切り替わる
  4. prefers-reduced-motion が有効なら一切動かさない

仕組みをざっくり#

View Transitions API は、DOM を書き換える前後のスナップショットを撮り、その 2 枚を疑似要素の木(::view-transition-old / ::view-transition-new)として重ねて補間します。同じ view-transition-name を持つ要素は、old と new が 1 つの ::view-transition-group にまとめられ、矩形の位置と大きさがアニメーションします。

React 19 の <ViewTransition> は、この名前付けと document.startViewTransition() の呼び出しを React の遷移(startTransition)に結びつけてくれます。Next.js の App Router はルーター遷移を startTransition で包んでいるので、ラッパーを置くだけでページ遷移がこの API に載ります。

app/[locale]/layout.tsx(抜粋)
import { ViewTransition } from "react";
 
export default function LocaleLayout({ children }: { children: React.ReactNode }) {
  return (
    <main>
      {/* 名前を付けないラッパーは、全ページ共通のクロスフェードになる */}
      <ViewTransition>{children}</ViewTransition>
    </main>
  );
}

共有要素にしたい側は、一覧と詳細の両方で同じ名前を付けます。

features/post/components/PostCard.tsx(簡略化)
export function PostCard({ post }: { post: Post }) {
  return (
    <ViewTransition name={`post-title-${post.slug}`}>
      <h2 className="w-fit font-bold text-xl">{post.title}</h2>
    </ViewTransition>
  );
}

ここまでは公式ドキュメントどおりです。問題はここからでした。

落とし穴 1:名前が衝突しても、何も言われない#

タグの名前を素直に post-tag-${tag} にすると、同じタグを持つ記事が一覧に 2 本並んだ時点で、1 枚のスナップショットの中に同じ名前が 2 つできます。view-transition-name はスナップショットの中で一意でなければならず、重なると遷移はスキップされます。

厄介なのは、そうなっても画面は普通の切り替えに戻るだけで、このサイトで試した範囲では React からの警告もありませんでした。「なぜか動かない」という症状しか残りません。

対処は単純で、名前を所有エンティティの slug でスコープします。

PostCard.tsx
- <ViewTransition name={`post-tag-${tag}`}>
+ <ViewTransition name={`post-tag-${post.slug}-${tag}`}>

以降、このサイトでは命名規則を {domain}-{element}-{slug}[-{subkey}] に固定し、デザインシステムのドキュメントに明記しています。規則にしておかないと、次に誰か(未来の自分)が新しい共有要素を足したときに同じ罠を踏みます。

落とし穴 2:テキストの「ボックス」が補間される#

名前を付けると見出しは動くようになりますが、移動中の見出しが滲んだ二重像になることがあります。短い見出しが横に引き伸ばされてから縮むような動きです。

原因は、補間の対象が文字の見た目の幅ではなく要素のボックスの矩形だったことです。見出しはブロック要素なので、テキストが短くても親の幅いっぱいを占めます。

上の 2 枚の見出し「『riku-mono.me』を公開しました」のボックス幅を、画面幅 1280px で測った値です。

ケース一覧のボックス幅詳細のボックス幅
w-fit なし(block のまま)546px984px
両側に w-fit320px528px

w-fit が無いと、補間されるのは 546px → 984px の矩形で、文字の大きさの変化とは関係なく横に大きく引き伸ばされます。左寄せのテキストなら、w-fit を付けても描画位置は変わりません。見た目を一切変えずに、補間の元になる矩形だけをテキストに密着させられます。

バッジ(inline-flex)はもともとボックスがテキストに密着しているので、何もしなくても綺麗に動きました。画像も aspect-video で比率を揃えておけば問題ありません。

落とし穴 3:スクロールしていると、カテゴリだけ動かない#

一番時間を使ったのがこれです。一覧を少し下までスクロールしてからカードを開くと、カテゴリのバッジだけが morph せずにフェードで切り替わりました。見出しとタグは動くのに、です。

何が起きていたか#

React は、遷移が始まった時点でビューポートに入っている要素にしか view-transition-name を付けません。しかも遷移先の DOM は「遷移元のスクロール位置のまま」測られます。当時の詳細ページでは、文書上の位置が上からカテゴリ・見出し・タグの順でした。

  • カテゴリ:164〜198px
  • 見出し:210〜285px
  • タグ:329〜355px

スクロールが深いほど、上にあるものから順にビューポートの外へ落ちます。カテゴリは詳細ページで一番上にある共有要素なので、真っ先に対象から外れていたわけです。「カテゴリだけ不安定」に見えたのは要素の種類のせいではなく、単に位置の順番でした。

クリック時の scrollYカテゴリ見出しタグ
150✅✅✅
233❌✅✅
300❌❌✅
450❌❌❌

対処:遷移の前にスクロールを戻す#

リンクのクリックを capture フェーズで拾い、遷移が始まる前にスクロールを先頭へ戻すコンポーネントを置きました。capture フェーズなのは、Next.js の <Link> が遷移を始める前に割り込むためです。ただし、何画面も下から一気に戻すと「クリックした場所と無関係な位置から飛んでくる」動きになるので、1 画面分以内のときだけ戻し、それより深ければ何もせず全体のクロスフェードに任せます。

shared/components/provider/ScrollResetBeforeNavigation.tsx(抜粋)
"use client";
 
import { useEffect } from "react";
 
export function ScrollResetBeforeNavigation() {
  useEffect(() => {
    const handleClick = (event: MouseEvent) => {
      if (event.defaultPrevented || event.button !== 0) return;
      if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
 
      const anchor = (event.target as Element | null)?.closest?.("a[href]");
      if (!(anchor instanceof HTMLAnchorElement)) return;
      const url = new URL(anchor.href, window.location.href);
      if (url.origin !== window.location.origin) return;
      // 同じページ内のアンカー(目次など)はスクロールを奪わない
      if (url.pathname === window.location.pathname) return;
 
      // 1 画面より深いところから戻すと、出発点が別物の morph になる
      if (window.scrollY === 0 || window.scrollY > window.innerHeight) return;
      window.scrollTo({ top: 0, left: 0, behavior: "instant" });
    };
    document.addEventListener("click", handleClick, true);
    return () => document.removeEventListener("click", handleClick, true);
  }, []);
 
  return null;
}

scrollTo(0, 0) が効いていなかった#

最初の実装は behavior: 'instant' ではなく、直前に scrollBehavior を書き換えてから scrollTo(0, 0) を呼ぶ形でした。<html> に scroll-smooth が付いているので、そのままだとアニメーションになってしまうからです。

ところが、これはまったく効いていませんでした。scrollTo は計算済みのスタイルを読むだけで、直前に書いたスタイルの変更を反映しません。React が遷移を始めるのはクリックの約 1ms 後なので、その時点でスクロールは 1px も動いていなかったのです。同じページで scrollY = 300 から試した、直後の値です。

書き方直後の scrollY
style.scrollBehavior = 'auto' → scrollTo(0, 0)300
上を !important で300
documentElement.scrollTop = 0300
スタイル変更 → 強制レイアウト → scrollTo(0, 0)0
scrollTo({ top: 0, behavior: 'instant' })0

ScrollToOptions の behavior は CSS の scroll-behavior より優先されるので、スタイルの反映順に依存しません。強制レイアウトを挟む案より副作用がないので、こちらを採りました1。

モーションの値をデザイントークンに寄せる#

::view-transition-* は通常の要素ツリーの外にある疑似要素なので、Tailwind のユーティリティが当たりません。何もしないと UA 既定の 250ms・linear で動き、サイトの他のアニメーション(200ms・独自のイージング)と速さも質感も揃いません。

styles/globals.css
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: var(--duration-normal);
}
 
/* 共有要素の移動は「往復する 1 つの動き」 */
::view-transition-group(*) {
  animation-timing-function: var(--ease-default);
}
 
/* old / new の入れ替えは「クロスフェード」 */
::view-transition-old(*),
::view-transition-new(*) {
  animation-timing-function: var(--ease-in-out);
}

React が自動で振る匿名の名前(_t_0_ など)は安定しないので、名前を個別に指定せず * で当てています。

reduced-motion の打ち消しを忘れない#

サイト全体では @media (prefers-reduced-motion: reduce) の中で *, ::before, ::after のアニメーションを止めていますが、::view-transition-* はこのセレクタにマッチしません。別の疑似要素ツリーに属しているからです。同じメディアクエリの中で個別に潰す必要があります。

styles/globals.css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
  }
}

まだ解けていないこと#

  • ブラウザバックでは動かない。 popstate 由来のナビゲーションでは startViewTransition が呼ばれません。React / Next.js 側の制約なので、今は詳細ページにパンくずを置き、<Link> で戻る導線を用意して回避しています。
  • 日付は共有要素にしていない。 一覧と詳細で日付の見た目が違うので、morph させると形が崩れます。見た目を揃えるかどうかを先に決める必要があります。
  • 関連記事のカードには名前を付けない。 記事 A から B へ移ったとき、B の関連記事に A のカードが並んでいると、A の見出しがページ下端のカードへ飛んでいってしまいます。

まとめ#

共有要素トランジションは、うまく動いているときは魔法のように見えますが、壊れているときは何も言わずに普通のフェードに戻るだけです。だからこそ、次の 3 点を規則として先に決めておくのが効きました。

  1. 名前は必ず所有エンティティの slug でスコープする
  2. 共有要素にするテキストは、両側のボックスをテキストに密着させる(w-fit)
  3. 遷移の前に、1 画面以内ならスクロールを先頭へ戻す

計測には Playwright を使いましたが、locator.click() は内部でスクロールを動かすので、スクロール位置を保ったままクリックしたいときは page.mouse.click(x, y) を使う必要があります。これに気づくまで、scrollY を 300 にしても 450 にしても、測るたびに 233 になる謎にも悩まされました(html の scroll-padding-top の分だけ、カードの上端に合わせてスクロールされていました)。


脚注

  1. 強制レイアウト(getBoundingClientRect() などの読み取り)を挟めば scrollBehavior の書き換えも反映されますが、レイアウト計算を 1 回余分に起こすうえ、「なぜここで読み取っているのか」をコメントで説明し続ける必要があります。 ↩