スコープ付きコンポーネントの移行

document.startViewTransition() でビュー遷移を開始すると、::view-transition オーバーレイが :root 要素にアタッチされます。これにより、ページの残りの部分でポインタ イベントがブロックされ、同時遷移が防止され、position: fixed ヘッダーや開いている [popover] 要素が隠されます(オーバーフローのブリードを防ぐためにネストされたビュー遷移グループが使用されている場合でも)。

コンポーネント ルートで element.startViewTransition() を呼び出すと、その DOM サブツリーに遷移のスコープを設定することで、この問題に対処できます。::view-transition 疑似要素ツリーが要素に直接挿入され、外部のスタック コンテキストとページのインタラクティビティを維持しながら、複数のコンポーネントを同時に遷移させることができます。

実装

1. スコープ付き遷移をトリガーする

document.startViewTransition() ではなく、コンポーネントのルート コンテナ要素で element.startViewTransition() を呼び出します。startViewTransition() を呼び出す要素が、遷移のスコープ(スコープ要素)になります。そのサブツリー内の要素のみがキャプチャされ、更新コールバック中にスコープのレンダリングのみが停止し、結果の ::view-transition オーバーレイはスコープ要素のサイズ、形状、スタッキング コンテキストと一致します。

2. 参加要素に名前を付ける

アニメーション化するスコープ内の子要素に view-transition-name を割り当てます。動的リストアイテムで view-transition-name: match-element を使用すると、各要素の遷移名を DOM ノード ID から自動的に取得したり、明示的な名前を割り当てたりできます(そのスコープ内で一意である必要があります)。

名前は各スコープ内で分離されるため、DOM の異なる部分にあるコンポーネント間で競合が発生することはありません。ただし、view-transition-name は、ネストされたスコープ付きビュー遷移の名前と競合する可能性があります。これを防ぐには、子スコープ要素に view-transition-scope: all を追加します。

3. 自己参加とオーバーフローのクリッピング

スコープ要素は、ユーザー エージェント スタイルシートが view-transition-name: root と view-transition-group: contain を適用するため、独自のビュー遷移に自動的に参加します。これにより、DOM で行った変更によってスコープのジオメトリが影響を受けると、ビュー遷移の一部として、子とともに独自のジオメトリがスムーズにアニメーション化されます。

スコープがオーバーフローをクリップする場合(overflow: hidden、scroll、clip)、::view-transition-group-children(root) は自動的に overflow: clip を適用し、遷移中の子がコンポーネントの境界外にはみ出さないようにします。

4. 同時遷移とネストされた遷移

個別のコンポーネント サブツリーは、互いをスキップすることなく element.startViewTransition() を同時に実行できます。アクティブなスコープは、UA スタイルシートで view-transition-scope: all を自動的に受け取るため、内部の view-transition-name 値が外部の遷移に漏洩することはありません。繰り返されるコンポーネント コンテナの CSS に view-transition-scope: all を適用して、コンポーネントがアイドル状態の場合でも、外部遷移が内部名をキャプチャしないようにします。

内部遷移がアクティブなときに外部遷移でコンポーネントを移動する必要がある場合は、内部スコープをラップする要素に view-transition-name を割り当てます。外側の遷移は、内側のスコープ要素ではなくラッパーのみをキャプチャしてアニメーション化するため、内側の遷移を完了まで実行できます。

例

スコープを確立するために、スコープ要素自体(.card または .card ul)に CSS は必要ありません。スコープは、document ではなく要素で startViewTransition() を呼び出すことで決定されます(コンポーネントが外部ビュー遷移内にネストされる場合は、view-transition-scope: all の外部遷移の実行をご覧ください)。

/* 1. Name participating child items */
.card ul li {
  view-transition-name: match-element;
}

/* 2. OPTIONAL: Customize transition timing (300ms is an example duration) */
::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: ease-in-out;
}

/* 3. MANDATORY: Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
// 1. Trigger a transition scoped to a single component subtree
function runScopedTransition(scopeEl, updateDOM) {
  // MANDATORY: Call startViewTransition on the component element, not document
  const transition = scopeEl.startViewTransition(() => {
    updateDOM();
  });

  // Re-triggering startViewTransition() on the same element skips the active transition
  // and rejects `ready`. Catch the rejection to avoid unhandled promise rejection errors.
  transition.ready.catch(() => {});
}

// 2. Reorder items by moving existing DOM nodes (required for `match-element`)
// Scope to `listEl` since only the list order changes, keeping controls outside `listEl` interactive
function reorderItems(listEl, orderedIds) {
  const existing = new Map(
    Array.from(listEl.children, (li) => [li.dataset.id, li]),
  );

  runScopedTransition(listEl, () => {
    for (const id of orderedIds) {
      const li = existing.get(id);
      if (li) listEl.appendChild(li);
    }
  });
}

// 3. Remove an item while preserving exit animation and keyboard focus
// Scope to the outer `cardEl` so the card itself also transitions its height change
function removeItem(cardEl, itemEl) {
  const hadFocus = itemEl.contains(document.activeElement);
  const nextFocusTarget =
    itemEl.nextElementSibling?.querySelector('button') ??
    itemEl.previousElementSibling?.querySelector('button') ??
    cardEl.querySelector('button');

  runScopedTransition(cardEl, () => {
    itemEl.remove();
    if (hadFocus) {
      nextFocusTarget?.focus();
    }
  });
}

外部遷移の実行

意図したスコープ外(親コンポーネントや、ページレベルの遷移のドキュメントなど)からビュー遷移を開始すると、外側の遷移がスコープ要素とその中のすべてをキャプチャしてアニメーション化します。

これを防ぐには、スコープ要素を独自の view-transition-name を持つ別のラッパー要素でラップし、内側のスコープ要素に view-transition-scope: all を適用します。これにより、ラッパー自体をキャプチャして外側の遷移でアニメーション化しながら、内側のスコープのコンテンツがキャプチャされないようにすることができます。

<div class="page" id="page">
  <div class="card-wrapper">
    <section class="card" id="left">
      <h2>Card A</h2>
      <ul id="left-list">
        <li>…</li>
      </ul>
    </section>
  </div>
  <div class="card-wrapper">
    <section class="card" id="right">
      <h2>Card B</h2>
      <ul id="right-list">
        <li>…</li>
      </ul>
    </section>
  </div>
</div>
document.querySelector('#page').startViewTransition(() => {
  // do your DOM updates here...
});
/* Apply a view-transition-name on the wrapper */
.card-wrapper {
  view-transition-name: match-element;
}

/* Prevent the contents of the card being captured by an outer transition */
.card {
  view-transition-scope: all;
}

.card ul li {
  view-transition-name: match-element;
}

または、外側の遷移で子をキャプチャできるようにします。クリッピング効果を維持するには、ネストされたビュー遷移グループを使用して、キャプチャされた子をラッパーの境界でクリップします。ただし、その場合は、結果の ::view-transition-group-children() 疑似に clip を手動で適用する必要があります。

.card-wrapper {
  overflow: clip;
  view-transition-name: match-element; /* Capture me as part of the outer VT */
  view-transition-group: contain; /* Nest children that also have a view-transition-name */
  view-transition-class: card; /* For targeting purposes */
}

/* Manually copy back the clip onto the ::view-transition-group-children pseudo */
::view-transition-group-children(.card) {
  overflow: clip;
}

.card ul li {
  view-transition-name: match-element;
}

制約とアクセシビリティ

  • 必須: document ではなく、コンポーネントのルート要素で startViewTransition() を呼び出します。これにより、外部の position: fixed 要素と [popover] オーバーレイが最上部に重ねて表示され、ページの残りの部分がインタラクティブなままになります。
  • 必須: view-transition-name: match-element を使用する場合は、既存の DOM ノードを(innerHTML で再作成するのではなく)保持して移動します。match-element は DOM ノードの ID を追跡するためです。
  • 必須: ::view-transition-group(*)、::view-transition-old(*)、::view-transition-new(*) で animation: none !important を設定して、@media (prefers-reduced-motion: reduce) を使用したモーションの軽減に関するユーザー設定を尊重します。
  • 必須: 遷移中にフォーカスされた要素を削除するときに、更新コールバック内で隣接するアイテムまたはコントロールにフォーカスを移すことで、キーボード フォーカスを保持します。
  • 推奨: スコープ内のキャプチャされた要素が独自のオーバーフローをクリップし、ネストされたキャプチャされた要素を含む場合は、ネストされたビュー遷移グループ(::view-transition-group-children() の view-transition-group: contain と overflow: clip)を使用します。
  • 推奨: 迅速な操作によって同じスコープ要素で element.startViewTransition() が再トリガーされる可能性がある場合は、transition.ready.catch(() => {}) をアタッチします。
  • すべきこと: 内部遷移の実行中に外部遷移でコンポーネントの位置をアニメーション化する必要がある場合は、内部スコープ要素を別の名前付きラッパー要素でラップします。
  • 禁止事項: オーバーフローをクリップする(overflow: hidden、scroll、clip)スコープ要素や、独自のジオメトリを変更するスコープ要素に view-transition-name: none を設定する。自己参加をオプトアウトすると、自動オーバーフロー クリップが無効になるためです。

代替戦略

Baseline ターゲットが要素スコープのビュー トランジションをサポートしていない場合は、アニメーションなしで DOM 更新コールバックをすぐに実行して、適切にデグレードします。document.startViewTransition() にフォールバックすることもできますが、ドキュメント スコープの遷移は同時コンポーネント遷移をブロックし、最上位レイヤの position: fixed 要素と [popover] 要素を隠すため、分離されたコンポーネントの更新にはおすすめしません。

// Feature detect specifically on Element.prototype (not document.startViewTransition)
const supportsScopedTransition = 'startViewTransition' in Element.prototype;

function runWithTransition(scopeEl, updateDOM) {
  if (!supportsScopedTransition) {
    // Fallback: apply the DOM update immediately without animation
    updateDOM();
    return;
  }

  const transition = scopeEl.startViewTransition(() => {
    updateDOM();
  });

  transition.ready.catch(() => {});
}

document.startViewTransition() を使用してフォールバックを実装する必要がある場合は、外部遷移の実行セクションで詳しく説明されているように、コンテナのクリッピング効果が保持されるようにする必要があります。このフォールバック アプローチでは、複数のビュー遷移を同時に実行できなくなり、最上位レイヤの position: fixed 要素と [popover] 要素が隠れてしまいます。