TECH · View Transitions API

遷移の動きをブラウザに任せる

サンプルとコード

01

startViewTransition の最小構成

単独で開く ↗

アニメーションのCSSを1行も書かずに、タブの中身がクロスフェードします。DOMの書き換えを関数で包むだけ、というのがこのAPIの全体像です。

HTML
<div class="tabs" role="tablist">
  <button class="tab" role="tab" aria-selected="true" data-tab="0">概要</button>
  <button class="tab" role="tab" aria-selected="false" data-tab="1">仕様</button>
  <button class="tab" role="tab" aria-selected="false" data-tab="2">料金</button>
</div>

<div class="panel" id="panel"></div>
CSS
/* 何も書かなくても既定でクロスフェードする。既定の名前 root は
   ページ全体のスナップショットに割り当てられている。
   時間や曲線だけ変えたいときは、この2つを上書きすればよい。 */
::view-transition-old(root) {
  animation-duration: 220ms;
  animation-timing-function: ease;
}
::view-transition-new(root) {
  animation-duration: 220ms;
  animation-timing-function: ease;
}

/* 動きを減らす設定では、遷移そのものを一瞬で終わらせる。
   切り替え自体は成立したまま、動きだけが消える。 */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation-duration: 1ms; }
}
JavaScript
const CONTENT = [
  { h: "概要", p: "画面の切り替わりを、前後のスナップショットからブラウザが補間します。座標を自分で測る必要はありません。" },
  { h: "仕様", p: "startViewTransition に渡した関数の中でDOMを書き換えます。書き換えが終わってから遷移が始まります。" },
  { h: "料金", p: "ブラウザに入っている機能なので、追加のライブラリも読み込みも必要ありません。" },
];

const panel = document.getElementById("panel");

function render(i) {
  const c = CONTENT[i];
  panel.innerHTML = "";
  const h = document.createElement("h2");
  h.textContent = c.h;
  const p = document.createElement("p");
  p.textContent = c.p;
  panel.append(h, p);
}

function select(i) {
  for (const b of document.querySelectorAll(".tab")) {
    b.setAttribute("aria-selected", String(Number(b.dataset.tab) === i));
  }
  render(i);
}

document.addEventListener("click", (e) => {
  const btn = e.target.closest(".tab");
  if (!btn) return;
  const i = Number(btn.dataset.tab);

  // 対応していないブラウザでは startViewTransition が存在しない。
  // その場合はそのまま書き換える。ここを省くと非対応環境で落ちる。
  if (!document.startViewTransition) {
    select(i);
    return;
  }

  // 渡した関数の中でDOMを書き換える。関数が同期的に終わった時点の
  // 見た目が「後」のスナップショットになる。
  const t = document.startViewTransition(() => select(i));

  // 遷移が中断されると ready と finished は reject する。タブを
  // 連打すれば普通に起きるので、握りつぶさないとコンソールに
  // Uncaught (in promise) が積み上がる。
  t.ready.catch(() => {});
  t.finished.catch(() => {});
});

select(0);
AIへの指示文
View Transitions API でタブの中身を切り替えてください。
要件:
- タブを押したら panel の中身を差し替える。差し替え処理は
  document.startViewTransition() に渡す関数の中で行う。
- document.startViewTransition が存在しない場合は、
  そのまま書き換えるだけの道を必ず用意する。
  これを省くと非対応ブラウザで落ちる、とコメントで書く。
- ::view-transition-old(root) と ::view-transition-new(root) で
  時間と曲線だけを指定する。root が既定の名前であることを書く。
- prefers-reduced-motion: reduce では animation-duration を 1ms にして、
  切り替えは成立させたまま動きだけ消す。
- role="tablist" と aria-selected を正しく付ける。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
02

一覧から詳細へ、画像をつなぐ

単独で開く ↗

押したサムネイルだけが拡大しながら移動します。座標も差分も計算していません。同じ名前を付けた要素を、ブラウザが同じものと見なして補間します。

CSS
/* つなげたい要素に同じ名前を付けると、前後で「同じもの」と見なされ、
   位置とサイズの差がそのまま動きになる。フェードではなく移動になる。
   名前はページ内で一意でなければならない。 */
.hero-shot { view-transition-name: hero-shot; }

/* 名前を付けた要素は root のスナップショットから切り離される。
   個別に時間や曲線を指定できる。 */
::view-transition-group(hero-shot) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(.2, .7, .2, 1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation-duration: 1ms; }
}
JavaScript
const ITEMS = [
  { id: "a", name: "夜明けの海", cls: "sw-a", text: "水平線の色が数分で変わっていく時間帯を、青の階調だけで表しています。" },
  { id: "b", name: "花の市", cls: "sw-b", text: "彩度の高い桃色を主役に置き、周囲の要素はすべて彩度を落としています。" },
  { id: "c", name: "苔の庭", cls: "sw-c", text: "緑を明度差だけで組み立て、輪郭を出さずに奥行きを作っています。" },
];

const view = document.getElementById("view");
let current = null; // null なら一覧

function renderList() {
  view.innerHTML = "";
  const grid = document.createElement("div");
  grid.className = "grid";
  for (const it of ITEMS) {
    const btn = document.createElement("button");
    btn.className = "item";
    btn.dataset.id = it.id;
    const thumb = document.createElement("div");
    // 「いま開こうとしているもの」だけに名前を付ける。
    // 全部に付けると名前が重複して遷移が止まる。
    thumb.className = "item-thumb " + it.cls + (it.id === current ? " hero-shot" : "");
    const name = document.createElement("span");
    name.className = "item-name";
    name.textContent = it.name;
    btn.append(thumb, name);
    grid.append(btn);
  }
  view.append(grid);
}

function renderDetail(id) {
  const it = ITEMS.find((x) => x.id === id);
  view.innerHTML = "";
  const box = document.createElement("div");
  box.className = "detail";
  const thumb = document.createElement("div");
  thumb.className = "detail-thumb " + it.cls + " hero-shot";
  const h = document.createElement("h2");
  h.textContent = it.name;
  const p = document.createElement("p");
  p.textContent = it.text;
  const back = document.createElement("button");
  back.className = "back";
  back.id = "back";
  back.textContent = "← 一覧へ戻る";
  box.append(thumb, h, p, back);
  view.append(box);
}

// 遷移の有無で処理を分けたくないので、書き換えだけを渡す形に寄せる。
function transition(update) {
  if (!document.startViewTransition) { update(); return; }
  const t = document.startViewTransition(update);
  // 遷移が中断されると ready と finished は reject する。前の遷移が
  // 終わる前に次を始めれば普通に起きるので、握りつぶさないと
  // コンソールに Uncaught (in promise) が積み上がる。
  t.ready.catch(() => {});
  t.finished.catch(() => {});
}

document.addEventListener("click", (e) => {
  const item = e.target.closest(".item");
  if (item) {
    current = item.dataset.id;
    // 名前を付けた状態の一覧を先に作ってから遷移すると、
    // 「どこから来たか」がブラウザに伝わる。
    renderList();
    transition(() => renderDetail(current));
    return;
  }
  if (e.target.closest("#back")) {
    transition(() => { renderList(); current = null; });
  }
});

renderList();
AIへの指示文
View Transitions で、一覧から詳細への遷移を作ってください。
要件:
- サムネイル一覧と詳細画面を、同じ領域の描き換えで切り替える。
- 開こうとしている項目のサムネイルと、詳細画面の大きな画像に
  同じ view-transition-name を付ける。位置とサイズの差が
  そのまま動きになる、という仕組みをコメントで書く。
- 名前はページ内で一意でなければならない。一覧の全項目に
  付けると重複して遷移が止まる、という注意を必ず書く。
- 遷移の直前に「名前を付けた状態の一覧」を描いてから
  startViewTransition を呼ぶ。
- ::view-transition-group(名前) で時間と曲線を指定する。
- prefers-reduced-motion: reduce では動きを止める。
03

並べ替えと削除をなめらかに

単独で開く ↗

行ごとに違う名前を付けると、並べ替えで各行がいまいる位置から次の位置へ移動します。消える行と現れる行だけ、別の動きに差し替えています。

HTML
<ul class="list" id="list"></ul>
CSS
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }

.row {
  display: grid;
  grid-template-columns: 2.2rem 1fr auto auto;
  align-items: center;
  gap: .7rem;
  padding: .7rem .9rem;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: #fff;
}
.row-rank {
  font-variant-numeric: tabular-nums;
  font-size: .8rem; line-height: 1.8; color: var(--muted);
}
.row-name { font-size: .92rem; line-height: 1.8; }
.row-score {
  font-variant-numeric: tabular-nums;
  font-size: .86rem; line-height: 1.8; color: var(--muted);
}
.row-del {
  border: 0; background: none; cursor: pointer; padding: .2rem .4rem;
  color: var(--muted); font-family: inherit; font-size: .82rem; line-height: 1.8;
}
.row-del:hover { color: var(--danger); }

/* 行ごとに違う名前を付ける。名前が一致した行だけが
   「移動した同じ行」として扱われ、並べ替えの軌跡が描かれる。
   名前は JS で style.viewTransitionName に入れる(下のJS参照)。 */

/* 消える行と現れる行の動きだけ差し替える。行数が可変なので、
   名前を1つずつ書く代わりに view-transition-class でまとめて当てる。
   比較的新しい機能で、未対応のブラウザではこの2行が無視され、
   既定のクロスフェードになる。動かなくなることはない。 */
::view-transition-old(.row-anim) { animation: row-out 200ms ease both; }
::view-transition-new(.row-anim) { animation: row-in 200ms ease both; }

@keyframes row-out {
  to { opacity: 0; transform: translateX(-12px); }
}
@keyframes row-in {
  from { opacity: 0; transform: translateX(12px); }
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation-duration: 1ms; }
}
JavaScript
const SOURCE = [
  { id: 1, name: "青木", score: 82 },
  { id: 2, name: "石田", score: 95 },
  { id: 3, name: "上原", score: 71 },
  { id: 4, name: "遠藤", score: 88 },
  { id: 5, name: "大石", score: 64 },
];
let rows = [...SOURCE];

const list = document.getElementById("list");

function render() {
  list.innerHTML = "";
  rows.forEach((r, i) => {
    const li = document.createElement("li");
    li.className = "row";
    // 行ごとに一意な名前を付ける。前後で同じ名前の行が
    // 「移動した同じ行」として補間される。
    li.style.viewTransitionName = "row-" + r.id;
    // 現れる/消える行の動きをまとめて指定するためのクラス。
    // 未対応のブラウザでは無視されるだけで、並べ替え自体は動く。
    li.style.viewTransitionClass = "row-anim";

    const rank = document.createElement("span");
    rank.className = "row-rank";
    rank.textContent = String(i + 1).padStart(2, "0");

    const name = document.createElement("span");
    name.className = "row-name";
    name.textContent = r.name;

    const score = document.createElement("span");
    score.className = "row-score";
    score.textContent = r.score + "点";

    const del = document.createElement("button");
    del.className = "row-del";
    del.dataset.del = String(r.id);
    del.setAttribute("aria-label", r.name + "を削除");
    del.textContent = "削除";

    li.append(rank, name, score, del);
    list.append(li);
  });
}

function update(fn) {
  if (!document.startViewTransition) { fn(); render(); return; }
  const t = document.startViewTransition(() => { fn(); render(); });
  // 遷移が中断されると ready と finished は reject する。並べ替えを
  // 連打すれば普通に起きるので、握りつぶさないとコンソールに
  // Uncaught (in promise) が積み上がる。
  t.ready.catch(() => {});
  t.finished.catch(() => {});
}

document.addEventListener("click", (e) => {
  const del = e.target.closest("[data-del]");
  if (del) {
    const id = Number(del.dataset.del);
    update(() => { rows = rows.filter((r) => r.id !== id); });
    return;
  }
  const btn = e.target.closest("[data-sort]");
  if (!btn) return;
  const kind = btn.dataset.sort;
  update(() => {
    if (kind === "score") rows = [...rows].sort((a, b) => b.score - a.score);
    else if (kind === "name") rows = [...rows].sort((a, b) => a.name.localeCompare(b.name, "ja"));
    else if (kind === "shuffle") rows = [...rows].reverse();
    else rows = [...SOURCE];
  });
});

render();
AIへの指示文
一覧の並べ替えと削除に View Transitions を使ってください。
要件:
- 行ごとに一意な view-transition-name を JavaScript で設定する
  (style.viewTransitionName に "row-" + id を入れる)。
- 得点順・名前順・逆順・元に戻す、の並べ替えと、行の削除を用意する。
- 状態を変えてから再描画するまでを1つの関数にまとめ、それを
  startViewTransition に渡す。非対応時は同じ関数をそのまま呼ぶ。
- 消える行と現れる行だけ、左右にずらしながらフェードする動きにする。
  行数が可変なので view-transition-class でまとめて指定し、
  未対応ブラウザでは既定のクロスフェードに戻るだけだと書く。
- 行数が多いとスナップショットが増えて重くなる、という注意を書く。
- prefers-reduced-motion: reduce では動きを止める。
04

擬似要素で動きを差し替える

単独で開く ↗

進むと左へ、戻ると右へ流します。方向をルート要素のdata属性で渡し、遷移中のCSSを切り替えるのが定石です。後始末まで含めて書いてあります。

HTML
<div class="steps" aria-hidden="true">
  <span class="dot on"></span><span class="dot"></span><span class="dot"></span>
</div>

<div class="stage">
  <div class="stage-inner" id="stage"></div>
</div>

<div class="nav">
  <button id="prev" disabled>← 戻る</button>
  <button id="next">次へ →</button>
</div>
CSS
/* 差し替えたい範囲だけに名前を付ける。ページ全体(root)を
   動かすとヘッダーまで滑ってしまい、目が疲れる。 */
.stage-inner { view-transition-name: stage; }

/* 前の中身と次の中身は、遷移中この2つの擬似要素として重なっている。
   既定ではどちらも opacity のアニメーションが当たっているので、
   上書きすれば好きな動きにできる。 */
::view-transition-old(stage) {
  animation: slide-out 240ms cubic-bezier(.4, 0, 1, 1) both;
}
::view-transition-new(stage) {
  animation: slide-in 240ms cubic-bezier(0, 0, .2, 1) both;
}

/* 戻るときは向きを反転させたい。方向は data 属性で外から与える。
   :root に付けた属性で遷移中のCSSを切り替えるのが定石。 */
:root[data-dir="back"] ::view-transition-old(stage) {
  animation: slide-out-back 240ms cubic-bezier(.4, 0, 1, 1) both;
}
:root[data-dir="back"] ::view-transition-new(stage) {
  animation: slide-in-back 240ms cubic-bezier(0, 0, .2, 1) both;
}

@keyframes slide-out { to { opacity: 0; transform: translateX(-24px); } }
@keyframes slide-in { from { opacity: 0; transform: translateX(24px); } }
@keyframes slide-out-back { to { opacity: 0; transform: translateX(24px); } }
@keyframes slide-in-back { from { opacity: 0; transform: translateX(-24px); } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(stage),
  ::view-transition-new(stage) { animation-duration: 1ms; }
}
JavaScript
const STEPS = [
  { h: "1. 送り先を選ぶ", p: "登録済みの住所から選ぶか、新しい住所を入力します。ここで選んだ内容は次の画面でも表示されます。" },
  { h: "2. 支払い方法", p: "クレジットカード、コンビニ払い、代金引換から選べます。あとから変更もできます。" },
  { h: "3. 内容の確認", p: "送り先と支払い方法、金額を確認します。確定するまで注文は成立しません。" },
];

const stage = document.getElementById("stage");
const prev = document.getElementById("prev");
const next = document.getElementById("next");
const dots = document.querySelectorAll(".dot");
let i = 0;

function render() {
  stage.innerHTML = "";
  const h = document.createElement("h2");
  h.textContent = STEPS[i].h;
  const p = document.createElement("p");
  p.textContent = STEPS[i].p;
  stage.append(h, p);
  dots.forEach((d, n) => d.classList.toggle("on", n <= i));
  prev.disabled = i === 0;
  next.disabled = i === STEPS.length - 1;
}

function go(delta) {
  const target = i + delta;
  if (target < 0 || target >= STEPS.length) return;

  // 向きをCSS側へ渡す。属性は遷移が終わったら必ず外す。
  // 付けっぱなしにすると次の遷移の向きが狂う。
  document.documentElement.dataset.dir = delta < 0 ? "back" : "forward";

  if (!document.startViewTransition) {
    i = target;
    render();
    delete document.documentElement.dataset.dir;
    return;
  }

  const t = document.startViewTransition(() => { i = target; render(); });

  // 後始末は成功時も中断時も必ず走らせる。then の第2引数を使うのは、
  // finally だと reject がそのまま素通りして
  // Uncaught (in promise) になるため。
  const cleanup = () => { delete document.documentElement.dataset.dir; };
  t.finished.then(cleanup, cleanup);
  t.ready.catch(() => {});
}

next.addEventListener("click", () => go(1));
prev.addEventListener("click", () => go(-1));
render();
AIへの指示文
3ステップの入力画面を、進む・戻るで向きが変わる遷移にしてください。
要件:
- 動かす範囲は中身だけに絞る。ページ全体を動かすとヘッダーまで
  滑って目が疲れる、という理由をコメントで書く。
- ::view-transition-old と ::view-transition-new に
  keyframes を当てて、既定のクロスフェードを置き換える。
- 進む・戻るの向きは :root の data-dir 属性で切り替える。
  遷移中のCSSを外から制御する定石だと書く。
- startViewTransition の戻り値の finished を使って、
  終わったら data-dir を必ず外す。付けっぱなしにすると
  次の遷移の向きが狂う、と書く。
- 遷移中はページ全体が操作を受け付けないので、
  250ミリ秒を超える動きにしない、という注意を書く。
- prefers-reduced-motion: reduce では動きを止める。

この技術について

画面が切り替わったとき、前と後ろのどこがつながっているのかを利用者に伝えたい。一覧で押したサムネイルが、詳細画面の大きな画像になる。その対応を動きで示せれば、説明の文章は要らなくなります。

従来これを実現するには、消える要素と現れる要素の座標を getBoundingClientRect で測り、差分を transform に変換し、アニメーションが終わったら後始末をする、という一連の作業が必要でした。View Transitions API は、それを全部ブラウザに任せます。

仕組みは1つだけ

document.startViewTransition(() => { ... }) に渡した関数の中でDOMを書き換える。それだけです。

ブラウザは関数を呼ぶ直前に「前」のスナップショットを撮り、関数が終わった直後に「後」のスナップショットを撮り、その間を補間します。何も指定しなければクロスフェードになります。アニメーションのCSSを1行も書かずに、まず動くところまで行けます。

startViewTransition に渡した関数の前後でスナップショットが自動的に撮られる 「前」を撮る 自動 DOM を書き換える 渡した関数の中 「後」を撮る 自動 間はブラウザが補間する 自分で書くのは真ん中の1つだけ。座標を測る処理も、後始末も要らない
従来は座標を測って差分を transform に変換し、終わったら片付けるという手順が必要でした。両端が自動になったので、書くのは状態を変える処理だけです。

非対応の道を必ず残す

最初に書くべきなのは、実は分岐のほうです。

if (!document.startViewTransition) {
  update();
  return;
}
document.startViewTransition(update);

対応していないブラウザでは document.startViewTransition が存在しません。この分岐が無いと例外が出て、アニメーションどころか切り替えそのものが止まります。書き換え処理を関数にまとめておけば、分岐は3行で済みます。

非対応ブラウザでは startViewTransition が存在しないので、分岐が無いと例外で止まる 分岐を書かない document .startViewTransition → undefined 例外が出て、切り替えそのものが止まる アニメーションどころの話ではない 3行の分岐を書く if (!document.start…) { update(); return; } 動きが付かないだけで機能は動く 書き換え処理を関数にまとめておけば、分岐はこの3行で済む
最初に書くべきなのは華やかなほうではなく、この分岐のほうです。書き換え処理を関数に切り出しておけば、対応状況を気にする箇所はここ1つに収まります。

名前を付けると、移動になる

つなげたい要素に view-transition-name を付けると、前後で「同じもの」と見なされます。すると位置とサイズの差がそのまま動きになり、フェードではなく移動として描かれます。

ここに一つだけ厳しい制約があります。同じ名前の要素が同時に2つ存在すると、その遷移は中断されて何も起きません。

一覧の全項目に同じ名前を付けてしまうのが典型的な失敗です。名前を付けるのは「いま開こうとしている1つ」だけ。一覧に戻ったら詳細側から外す。この出し入れを状態と一緒に管理してください。

同じ view-transition-name を付けた要素は、位置とサイズの差がそのまま移動になる 名前を付けない 全体がふわっと入れ替わる(クロスフェード) 同じ名前を付ける A A 位置とサイズの差が移動になる 同じ名前が同時に2つあると、その遷移は中断されて何も起きない 一覧の全項目に付けてしまうのが典型。付けるのは「いま開こうとしている1つ」だけ
右の図で、名前が付いているのは2つで1組の要素だけです。一覧の残り3枚には付いていません。ここを取り違えると、動かないのではなく何も起きなくなります。

動かす範囲を絞る

名前を付けた要素は、ページ全体を表す root のスナップショットから切り離されます。裏を返せば、名前を付けない限りページ全体が一枚絵として動くということです。

タブの中身を切り替えただけなのに、ヘッダーもサイドバーも一緒にフェードすると、目の置きどころが無くなります。動かしたい範囲に名前を付けて、そこだけを対象にしてください。

名前を付けない限りページ全体が一枚絵として動く 名前を付けない ヘッダー ページ全体が一枚絵として動く 目の置きどころが無くなる 動かす範囲に名前を付ける ヘッダー 名前を付けた範囲だけが動く
左はヘッダーごと動いています。中身を少し替えただけなのに、画面全体が揺れるのがこの状態です。動かしたい箇所を1つ決めて、そこにだけ名前を置いてください。

向きは属性で渡す

進むときは左へ、戻るときは右へ流したい。この「向き」はCSSからは分かりません。

定石は、遷移を始める前にルート要素へ data-dir="back" のような属性を付け、:root[data-dir="back"] ::view-transition-old(...) で動きを切り替えることです。そして startViewTransition() の戻り値が持つ finished必ず属性を外します。付けっぱなしにすると、次の遷移の向きが狂います。

速さは見た目より優先する

遷移が走っている間、ページ全体は操作を受け付けません。つまりアニメーションの長さは、そのまま「操作できない時間」です。

200から300ミリ秒に収めてください。それを超えると、なめらかさより待たされる感覚のほうが強く残ります。そして prefers-reduced-motion では animation-duration1ms にして、切り替えは成立させたまま動きだけを消します。JavaScript側を分岐させる必要はありません。

向きは data 属性で CSS に渡し、遷移が終わったら必ず外す :root[data-dir="back"] 遷移を始める前に属性を付ける CSS 側で動きの向きを切り替える t.finished → 属性を外す 成功しても中断されても必ず外す 外し忘れると次の向きが狂う 遷移中はページ全体が操作を受け付けない アニメーションの長さ = 操作できない時間。200〜300ミリ秒に収める
「向き」は CSS からは分かりません。属性で渡すのが定石ですが、付けたら必ず外すところまでが一組です。finally では reject が素通りするので then(cleanup, cleanup) を使います。

使いどころとつまずきどころ

向いている場面

  • 一覧から詳細へ、同じ画像を拡大しながら遷移する画面
  • 並べ替えや絞り込みで並びが変わる一覧
  • タブやステップの切り替え
  • 追加・削除のあるリスト

つまずきやすい点

  • view-transition-name は同時に1つしか存在できない
  • 非対応ブラウザ向けに、遷移なしで動く道を必ず残す
  • prefers-reduced-motion では動きを止める
  • 遷移中はページ全体が操作できない。長い動きにしない

よくある質問

対応していないブラウザではどうなりますか。

document.startViewTransition が undefined になります。その場合はDOMの書き換えだけを実行する道を用意しておけば、アニメーションが付かないだけで機能は普通に動きます。この分岐を書き忘れると、非対応ブラウザで例外が出て切り替えそのものが止まります。必ず入れてください。

view-transition-name はページ内で重複してもよいですか。

いけません。同じ名前の要素が同時に2つ存在すると、その遷移は中断されて何も起きません。一覧の全項目に同じ名前を付けてしまう間違いが最も多いので、「いま開こうとしている1つ」にだけ付けて、それ以外からは外してください。

ページ全体ではなく一部だけを動かせますか。

できます。動かしたい範囲に view-transition-name を付けると、その要素は root のスナップショットから切り離され、個別にアニメーションできます。ヘッダーやサイドバーまで一緒に滑ると目が疲れるので、実務では範囲を絞るほうが結果がよくなります。

ページ遷移そのものには使えますか。

使えます。同一オリジンのページ間なら、CSSに @view-transition { navigation: auto } と書くだけで、通常のリンク遷移にも適用されます。ここで紹介しているのは1ページ内でDOMを書き換える使い方ですが、view-transition-name の考え方はどちらでも同じです。

アニメーションの時間はどのくらいが適切ですか。

200から300ミリ秒に収めてください。遷移中はページ全体が操作を受け付けないため、長い動きはそのまま操作できない時間になります。「きれいに見える長さ」ではなく「待たされない長さ」で決めるのが実用的です。

prefers-reduced-motion にはどう対応しますか。

擬似要素の animation-duration を 1ms にするのが簡単です。遷移そのものは成立したまま、動きだけが消えます。処理を分岐させる必要がないので、JavaScript側は何も変えずに済みます。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術