GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

view transitions api 9パターン — 値で画面接続

view transitions api の9パターンをまとめました。画面はそのままつながって見えるが、実際は直前の画面をスナップショット値として残し次の画面と重ねて過ぎ去る — 名前値で要素を対応させ、対応可否の値で退避し、モーション低減値で継続時間を切る。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

以下の9つのパネルはどれも、何も取得したり実際のページへ移動したりしません — 項目02の文書間遷移は、この1ページの中だけで住所欄の表示とパネルの入れ替えを使って再現した見せかけで、本物のドキュメント読み込みはありません。ここでは Math.random も外部ライブラリも使っていません。中身を本物のコンテンツに差し替えても、下の遷移ロジックはそのまま使えます。

対応可否の値を先に確認する07を、他の8つより先に読む理由

01から09までのうち07以外の8つは、どれも同じ小さな change(fn) 関数を呼んでいます。この関数自体が 'startViewTransition' in document の値を先に確認し、document.startViewTransition(fn) を使うか fn() をそのまま実行するかを決めています。07はその確認を関数の中に隠さず、確認そのものをデモの主題にしています。07を先に読むと、残り8つは別々の9個の小技ではなく、名前値・クリック位置・方向値・クラス・並び順・モーション値という8つの違う値を、同じ1つの駆動ロジックに渡しているだけだと分かります。

01固有の名前値で要素を対応させてつなぐ遷移

カードと詳細パネルはマークアップも位置も別々ですが、開く瞬間に両方へ同じ view-transition-name 値を与えると、ブラウザは2つのスナップショットを1つの要素が伸び縮みするかのように補間します。名前値を持てるのは開いているカード1枚だけで、3枚全部に同時に付けると名前が衝突し対応が壊れます。

固有の名前値で要素を対応させてつなぐ遷移
新しいタブで開く

01 · 固有の名前値で要素を対応させてつなぐ遷移 — 計測 累積面積 44.292% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

view-transition-name::view-transition-group(np-shared)$duration
  // 計算層: 名前値(view-transition-name)は、その瞬間に選ばれているカード1枚だけに渡す —
  // 全カードに常時つけると名前が衝突し対応が壊れる。値は毎回その場で計算し直す。
  function assignName(id, on) {
    cards[id].style.viewTransitionName = on ? 'np-shared' : '';
  }

  function openRender(id) {
    if (opened !== null) assignName(opened, false);
    assignName(id, true);
    detail.style.viewTransitionName = 'np-shared';
    grid.hidden = true;
    detail.hidden = false;
    detailBadge.textContent = DATA[id].b;
    detailTitle.textContent = DATA[id].t;
    nameEl.textContent = 'card-' + id;
    opened = id;
  }

  function closeRender() {
    detail.hidden = true;
    grid.hidden = false;
    if (opened !== null) assignName(opened, false);
    detail.style.viewTransitionName = '';
    opened = null;
  }

02実際の文書遷移でも途切れない文書間トランジション

このデモは実際の文書間遷移をまるごと1ページの中だけで再現した見せかけです — アドレスバーの表示と入れ替わる2枚のパネルが2つの別々の文書の代わりを務めていて、実際の再読み込みも取得も一切起きません。本物の複数ページサイトなら @view-transition { navigation: auto } でこの同じ切り替えに加わることになり、その view transition name auto の働きこそが、ここで change() が document.startViewTransition を使って手動で対応させているのと同じ形で、2つのまったく別の文書をまたいで要素を対応させる仕組みです。

実際の文書遷移でも途切れない文書間トランジション
新しいタブで開く

02 · 実際の文書遷移でも途切れない文書間トランジション — 計測 累積面積 11.061% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

view-transition-name::view-transition-group(root)startViewTransition
  function toListRender() {
    urlEl.textContent = 'godrichstory.example/list';
    article.hidden = true;
    list.hidden = false;
    atArticle = false;
  }

  function change(fn) {
    if (SUPPORTS_VT) { document.startViewTransition(fn); }
    else { fn(); }
  }

  function toArticle() {
    if (atArticle) return;
    change(toArticleRender);
  }
  function toList() {
    if (!atArticle) return;
    change(toListRender);
  }

  openBtn.addEventListener('click', function () { human(); toArticle(); });
  backBtn.addEventListener('click', function () { human(); toList(); });

03クリック地点の値から丸く広がる遷移

farthestCornerRadius はクリック座標からパネルの4隅までの距離を測り、いちばん遠い値を採用します。だから円をその半径まで育てた瞬間、パネル全体がちょうど覆われます。playClip は遷移を開始して t.ready を待ち、新しい疑似要素の clip-path を、ほぼ0の半径から計算済みの半径まで動かします。

クリック地点の値から丸く広がる遷移
新しいタブで開く

03 · クリック地点の値から丸く広がる遷移 — 計測 累積面積 29.044% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

farthestCornerRadius::view-transition-new(cr-panel)CLIP_START_RADIUS_PX
  function playClip(x, y, rect) {
    if (!SUPPORTS_VT || !panel.animate) { renderState(); return; }
    var radius = farthestCornerRadius(x, y, rect);
    panel.style.viewTransitionName = 'cr-panel';
    var t = document.startViewTransition(function () { renderState(); });
    t.ready.then(function () {
      document.documentElement.animate(
        { clipPath: ['circle(' + CLIP_START_RADIUS_PX + 'px at ' + x + 'px ' + y + 'px)', 'circle(' + radius.toFixed(1) + 'px at ' + x + 'px ' + y + 'px)'] },
        { duration: 520, easing: 'ease-in', pseudoElement: '::view-transition-new(cr-panel)' }
      );
    });
    t.finished.then(function () { panel.style.viewTransitionName = ''; });
  }

  function toggleAt(x, y) {
    dark = !dark;
    var rect = panel.getBoundingClientRect();
    var lx = x - rect.left;
    var ly = y - rect.top;
    playClip(lx, ly, rect);
  }

04親子を一組として一緒に動かす入れ子遷移

外側のカードは自分の名前値を持ち、中のサムネイルとタイトルもそれぞれ別の名前値を持っています。下のCSSがこの3つを1つの ::view-transition-group のタイミングにまとめているので、外枠が動くあいだ子要素も一緒に追従し、最終サイズに半コマ遅れて追いつくということが起きません。

親子を一組として一緒に動かす入れ子遷移
新しいタブで開く

04 · 親子を一組として一緒に動かす入れ子遷移 — 計測 累積面積 44.171% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

::view-transition-group(gn-card)::view-transition-group(gn-thumb)::view-transition-group(gn-title)
  function render() {
    card.classList.toggle('is-open', open);
  }

  function change(fn) {
    if (SUPPORTS_VT) { document.startViewTransition(fn); }
    else { fn(); }
  }

  function toggle() {
    open = !open;
    change(render);
  }

  card.addEventListener('click', function () { human(); toggle(); });
  root.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' || e.key === ' ') { human(); e.preventDefault(); toggle(); }
  });

05要素群ごとに異なる減速値を与えるクラス指定遷移

ヒーロー要素とボタンは、それぞれ違う view-transition-class 値(vt-slow と vt-fast)を持っています。クラスごとの ::view-transition-group(.クラス) ルールが個別の継続時間と減速値を持っているので、同じクリック1回で背景はゆっくり、ボタンは速く動きます。

要素群ごとに異なる減速値を与えるクラス指定遷移
新しいタブで開く

05 · 要素群ごとに異なる減速値を与えるクラス指定遷移 — 計測 累積面積 29.267% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

view-transition-class::view-transition-group(.vt-slow)::view-transition-group(.vt-fast)
// 実用の原理: 要素ごとに違う view-transition-class 値(vt-slow・vt-fast)を付けておくと、
// 名前がばらばらでも ::view-transition-group(.クラス) でまとめて継続時間を分けられる —
// 背景はゆっくり(減速値が大きい)、ボタンは速く(減速値が小さい)、同じきっかけで速度だけ変わる。
::view-transition-group(.vt-slow) {
  animation-duration: $duration / 2.2;
  animation-timing-function: $easing;
}
::view-transition-group(.vt-fast) {
  animation-duration: $dur-quick;
  animation-timing-function: $ease-pop;
}

06移動方向の値を読んで反転するスライド遷移

goto は次のインデックスから現在のインデックスを引いた値の符号だけを方向値として取り(+1 前へ・-1 後ろへ)、遷移が始まる前に --dir カスタムプロパティへ書き込みます。同じスライドのキーフレームがそのプロパティを読んで、次の画面がどちら側から入るかを決めます — キーフレームは1組のまま、方向だけが反転します。

移動方向の値を読んで反転するスライド遷移
新しいタブで開く

06 · 移動方向の値を読んで反転するスライド遷移 — 計測 累積面積 31.792% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--dir::view-transition-old(ds-step)$slide-travel-pct
  function render(newIdx) {
    numEl.textContent = String(newIdx + 1);
    tEl.textContent = STEPS[newIdx];
    dots.forEach(function (d, i) { d.classList.toggle('is-active', i === newIdx); });
    idx = newIdx;
  }

  function change(fn) {
    if (SUPPORTS_VT) { document.startViewTransition(fn); }
    else { fn(); }
  }

  function goto(newIdx) {
    if (newIdx === idx || newIdx < 0 || newIdx > 3) return;
    // 計算層: 次のインデックス引く現在のインデックスの符号だけを取り、移動方向の値を得る(+1 前へ・-1 後ろへ)
    var dir = Math.sign(newIdx - idx) || 1;
    document.documentElement.style.setProperty('--dir', String(dir));
    change(function () { render(newIdx); });
  }

07対応可否の値を先に確認して退避する遷移

step() は SUPPORTS_VT && !simulateUnsupported の値を1つの真偽値にまとめてから、ほかの何よりも先に読みます。真なら document.startViewTransition(applyState) を、偽なら applyState() をそのまま呼びます。対応していない環境でもアニメーションなしで正しい状態に即座に変わるので、画面が止まったり空白になったりしません。

対応可否の値を先に確認して退避する遷移
新しいタブで開く

07 · 対応可否の値を先に確認して退避する遷移 — 計測 累積面積 9.112% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

startViewTransition::view-transition-old(sf-card)SUPPORTS_VT
  function applyState() {
    si = (si + 1) % STATES.length;
    numEl.textContent = STATES[si];
    card.classList.toggle('is-alt', si % 2 === 1);
  }

  // この関数が規則の核心: 対応可否の値をまず確認し(SUPPORTS_VT)、シミュレーションで
  // 「対応していない環境」を再現するときは、実際の対応可否に関係なく即座に切り替える経路へ退避する。
  function step() {
    var willUseTransition = SUPPORTS_VT && !simulateUnsupported;
    if (willUseTransition) {
      document.startViewTransition(applyState);
    } else {
      applyState();
    }
    renderLabels(willUseTransition);
  }

08並び順の値が変わるとき滑る遷移

カードはそれぞれ、データid値から作った固有の lr-item-<id> という名前値を最初に一度だけ持ちます。だから並べ替えのたびに名前を付け直す必要がありません — render() は配列を並べ替えて appendChild を新しい順で呼ぶだけで、名前値が一致しているからこそブラウザは各カードを新しいレイアウトへ一足飛びに変えるのではなく、元の位置から新しい位置まで滑らせられます。

並び順の値が変わるとき滑る遷移
新しいタブで開く

08 · 並び順の値が変わるとき滑る遷移 — 計測 累積面積 26.685% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

viewTransitionName::view-transition-group(*)appendChild
  // 計算層: カードごとにデータid値から固有の名前値を作って付ける — 順序が変わっても
  // 同じカードは同じ名前を保つので、ブラウザは「このカードがあの位置へ移動した」と判断できる。
  items.forEach(function (el) {
    el.style.viewTransitionName = 'lr-item-' + el.dataset.id;
  });

  var asc = true;
  var isDemo = true;

  function human() {
    if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
  }

  function render() {
    var sorted = items.slice().sort(function (a, b) {
      var av = Number(a.querySelector('.lr__price').dataset.v);
      var bv = Number(b.querySelector('.lr__price').dataset.v);
      return asc ? av - bv : bv - av;
    });
    sorted.forEach(function (el) { list.appendChild(el); });
    sortBtn.textContent = asc ? '価格の昇順' : '価格の降順';
  }

09モーション低減値を検知して遷移を切る安全装置

effectiveReduce() は実際のシステム値とデモ用のシミュレーション値をORでまとめて1つの効果値にし、step() はその値が偽で対応可否も真のときだけ startViewTransition を呼びます。それ以外は継続時間の表示を0にして render() を直接呼ぶので、システムがモーション低減を求めているあいだ、上の8つの遷移はどれも一切再生されません。

モーション低減値を検知して遷移を切る安全装置
新しいタブで開く

09 · モーション低減値を検知して遷移を切る安全装置 — 計測 累積面積 8.36% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

effectiveReduce::view-transition-new(rg-card)SUPPORTS_VT
  // 計算層: 実際の動作軽減の値とデモ用のシミュレーション値を合わせて1つの効果値にし、その値によって
  // 継続時間を0にするか通常値のままにするかを決める — アクセシビリティの判定には常にREAL_REDUCEが含まれる。
  function effectiveReduce() { return REAL_REDUCE || simulateReduce; }

  function step() {
    var reduceNow = effectiveReduce();
    var dur = reduceNow ? 0 : NORMAL_MS;
    reduceEl.textContent = reduceNow ? '動作軽減オン' : '動作軽減オフ';
    durVEl.textContent = String(dur);

    if (SUPPORTS_VT && !reduceNow) {
      document.startViewTransition(render);
    } else {
      render();
    }
  }

9つの遷移が実測でどこまで動いているか

項目 何を計算して切り替えるか 変化した面積 動きの強さ 動いたフレーム数(23枚中)
01 名前値で対応させたスナップショット補間 44.3% 59.7 7/23
02 文書間遷移を模したスナップショット入れ替え 11.1% 35.8 10/23
03 クリック地点から広がるクリップパス半径 29.0% 167.8 12/23
04 親子をまとめたグループスナップショット移動 44.2% 116.4 12/23
05 クラスごとの継続時間・減速値の分離 29.3% 122.8 14/23
06 方向フラグで反転するスライドのキーフレーム 31.8% 90.5 6/23
07 対応可否を確認したうえでの退避分岐 9.1% 93.2 9/23
08 並び順の値が動かすカードの再配置 26.7% 59.4 12/23
09 モーション低減値で切る継続時間 8.4% 150.7 6/23

07と09が変化した面積の欄で下位にあるのは偶然ではありません — どちらも、遷移がほとんど動かない分岐そのものを測っています。07は対応可否をシミュレーションで外している間、09はモーション低減がオンの間です。

動かせる定数3つと、動かすと何が変わるか

変形 項目 定数 基準 → 変形 何が変わるか
01 名前対応スワップパネル $duration 600ms → 300ms 2枚のスナップショットが重なる時間が縮まり、切り替えは徐々ににじむのではなく急な切断に見える
03 円形リビールパネル CLIP_START_RADIUS_PX 0 → 48 円が最初から広い状態で始まり、点から広がるというより素早い上書き切り替えのように見える
06 方向スライドパネル $slide-travel-pct 100% → 40% スライドが終わっても次の画面が手前の画面に重なったままになり、完全に外側から入ってこなくなる

reduced-motion の値がすべての遷移を上書きする場所

01から06と08はそれぞれ、システムの prefers-reduced-motion の値を一度だけ reduce フラグへ読み込んでいます。09だけは同じ実際の値を effectiveReduce() の中で自分のシミュレーション値と合わせています。すべての項目の下では1つの共通ルールが実際の仕事をしています — @media (prefers-reduced-motion: reduce) の中の ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } が疑似要素のアニメーションを完全に取り除くので、状態の切り替えは動いたフレーム0枚のまま即座に終わります。これは07が対応可否の値そのものが無いときに通る即時経路と同じです。

どこで壊れるか — 名前の衝突・遷移の割り込み・スナップショットのない要素

同じ view-transition-name 値を2つの要素へ同時に与えると、ブラウザは1つの名前につき1つのスナップショットしか対応させられません — 項目01の assignName が、次のカードへ渡す前に直前に開いていたカードの名前を必ず外しているのはそのためで、この手順を飛ばすと対応が衝突します。最初の startViewTransition の t.finished が解決する前に2回目を発火させると、1回目の遷移はアニメーションなしでそのまま打ち切られます。そして display: none の要素に view-transition-name を付けても何も起きません — ブラウザがそもそもその要素のボックスを描画していないので、対応させるスナップショット自体が存在しないからです。ここまでの内容を実際に再現するには抜粋ではなく本物のソースが要ります — 下のzipには9本の index.html と3つの変形がすべて入っていて、パスワード va4qdsmz で施錠されています。答えを隣に表示するページから貼り付けるのではなく、パスワードは手で打ち込む方が意味があります。

名前対応の遷移が実際の画面で役に立つ場所

商品一覧から詳細画面へ開く場面は、01の名前対応の技がいちばん分かりやすく効く場所です — クリックしたサムネイルは、消えて別物に置き換わるのではなく、詳細レイアウトの中でも視覚的に同じ要素であり続けます。04はその直後に出てくる不満に答えます — カードがその大きさまで広がると、サムネイルとタイトルもカードの中での相対位置を保つ必要があり、外枠だけ先に広がって中身が半コマ遅れて追いつくのでは困るからです。

06の方向フラグと08の並び順の値は、状態にすでに「どちら向きか」「どの位置か」がはっきり組み込まれている場面ならどこにでも合います — ウィザードのステップ数、並べ替えのドロップダウン、かんばんの列などです。どちらも、実データにつなぐときに変わるのは idx や比較関数が数値をどこから受け取るかだけで、下の遷移ロジック自体は変わりません。

キャンバスのリビールやタブパネルのcssスワップとどう違うか

キャンバスのリビールは、見えるピクセルすべてを getImageData の読み取りとペインターズアルゴリズムのループで、1フレームごとに2Dキャンバスへ手で描き直します。ここに集めた9つはどれもその作り方ではありません — 実際のピクセルはブラウザ自身のスナップショット機構に渡され、::view-transition-old(name) と ::view-transition-new(name) はブラウザが描く疑似要素です。この記事のview transitions api cssルールはすべてその疑似要素の中にあり、上のJavaScriptが設定するのは名前値・クリップ半径・方向フラグといった値だけで、それをCSSかWeb Animations APIが読み取ります。

タブパネルのcssスワップは、実際の要素にもとから付いている height や transform や clip-path といったプロパティ自体を transition で補間します。この9つは実際の要素の transition に触れることが一切ありません — 常に document.startViewTransition が進行中のあいだだけブラウザが作る、並行した疑似要素ツリーを経由します。だからこそ、切り替えの最中に本物のDOMを覗いても、動いているものはそこに見当たりません。

view transitions api 使い方 の実例をもっと見るには

view transitions api と は 何かを調べると、単発のデモが方々に散らばっているのがほとんどです。ここに集めた9つは、名前値・対応可否の値・モーション値というわずかな共有値だけで駆動していて、バラバラの9個の小技ではありません。zipにはすべてのルールとJSドライバーが入っているので、描画済みのページから逆算するのではなく、ソースをそのまま読めます。

この9つのview transitions api パターンについてよくある疑問

view transitions api demo に外部ライブラリは必要ですか

いいえ——上の9つのデモはすべて素のDOM操作と document.startViewTransition、いくつかのCSS疑似要素だけで動いています。アニメーションライブラリもCDNスクリプトもフレームワークも、9本のファイルのどこにも入っていません。

パターン07と09は、なぜ動いたフレーム数がこんなに少ないのですか

どちらも意図してそうなっています——07はそもそも遷移が一切走らない分岐こそが主題で、09はモーション低減の値がオンのとき継続時間を0にする設計なので、デモの不具合ではありません。

同じ名前値を2つの要素へ誤って付けてしまうとどうなりますか

ブラウザは1つの名前につき1つのスナップショットしか対応させられません——項目01の assignName 関数が、次のカードへ渡す前に直前のカードの名前を必ず外しているのはまさにそのためで、その手順を飛ばすのが上の「どこで壊れるか」で説明した失敗そのものです。

Sources: MDN — View Transitions API · MDN — view-transition-name · MDN — @view-transition · MDN — Document: startViewTransition() method · MDN — prefers-reduced-motion

Related reading: 【一行でコピペOK】CSS ダークモード 切り替え アニメーション9選 · 一行でコピペOK CSS アニメーション ギャラリー 9選 · 画像 切り替え エフェクト 9種 — キャンバスが計算するリビール — 1つ目もパターン03と同じようにクリック地点から円を広げますが、疑似要素ではなく素の clip-path トランジションで動かしています。2つ目はパターン08が並び順の値を計算するのと同じ考え方でタイルごとの表示タイミングを計算していますが、対象はクリックで動くリストではなくホバーで動くギャラリーです。3つ目は上の比較で挙げたキャンバス版そのもので、ブラウザのスナップショット版の代わりにgetImageDataで計算するワイプを読むならこちらです。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。