TECH · View Transitions API
遷移の動きをブラウザに任せる
View Transitions APIのサンプル集。startViewTransitionによるクロスフェード、view-transition-nameで要素をつなぐ拡大、一覧の並べ替えと削除、擬似要素での独自の動きを、非対応環境への備えまで含めて収録します。
サンプルとコード
アニメーションの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 にする。
押したサムネイルだけが拡大しながら移動します。座標も差分も計算していません。同じ名前を付けた要素を、ブラウザが同じものと見なして補間します。
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 では動きを止める。
行ごとに違う名前を付けると、並べ替えで各行がいまいる位置から次の位置へ移動します。消える行と現れる行だけ、別の動きに差し替えています。
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 では動きを止める。
進むと左へ、戻ると右へ流します。方向をルート要素の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行も書かずに、まず動くところまで行けます。
非対応の道を必ず残す
最初に書くべきなのは、実は分岐のほうです。
if (!document.startViewTransition) {
update();
return;
}
document.startViewTransition(update);
対応していないブラウザでは document.startViewTransition が存在しません。この分岐が無いと例外が出て、アニメーションどころか切り替えそのものが止まります。書き換え処理を関数にまとめておけば、分岐は3行で済みます。
名前を付けると、移動になる
つなげたい要素に view-transition-name を付けると、前後で「同じもの」と見なされます。すると位置とサイズの差がそのまま動きになり、フェードではなく移動として描かれます。
ここに一つだけ厳しい制約があります。同じ名前の要素が同時に2つ存在すると、その遷移は中断されて何も起きません。
一覧の全項目に同じ名前を付けてしまうのが典型的な失敗です。名前を付けるのは「いま開こうとしている1つ」だけ。一覧に戻ったら詳細側から外す。この出し入れを状態と一緒に管理してください。
動かす範囲を絞る
名前を付けた要素は、ページ全体を表す root のスナップショットから切り離されます。裏を返せば、名前を付けない限りページ全体が一枚絵として動くということです。
タブの中身を切り替えただけなのに、ヘッダーもサイドバーも一緒にフェードすると、目の置きどころが無くなります。動かしたい範囲に名前を付けて、そこだけを対象にしてください。
向きは属性で渡す
進むときは左へ、戻るときは右へ流したい。この「向き」はCSSからは分かりません。
定石は、遷移を始める前にルート要素へ data-dir="back" のような属性を付け、:root[data-dir="back"] ::view-transition-old(...) で動きを切り替えることです。そして startViewTransition() の戻り値が持つ finished で必ず属性を外します。付けっぱなしにすると、次の遷移の向きが狂います。
速さは見た目より優先する
遷移が走っている間、ページ全体は操作を受け付けません。つまりアニメーションの長さは、そのまま「操作できない時間」です。
200から300ミリ秒に収めてください。それを超えると、なめらかさより待たされる感覚のほうが強く残ります。そして prefers-reduced-motion では animation-duration を 1ms にして、切り替えは成立させたまま動きだけを消します。JavaScript側を分岐させる必要はありません。
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側は何も変えずに済みます。