作用域组件转换

使用 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 节点标识自动派生其过渡名称,或分配显式名称(只需在该范围内保持唯一即可)。

名称在每个范围内都是隔离的,可防止 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 即可建立范围,范围是通过对元素调用 startViewTransition() 而不是 document 来确定的(如果组件将嵌套在外部视图过渡中,请参阅运行外部过渡,了解 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();
    }
  });
}

运行外部过渡

在预期范围之外(例如,从父组件或从页面级过渡的文档)启动视图过渡时,外部过渡会捕获范围元素及其中的所有内容,并为其添加动画效果。

为防止出现这种情况,请将 scope 元素封装在具有自己的 view-transition-name 的单独封装容器元素中,并将 view-transition-scope: all 应用于内部 scope 元素。这样,封装容器本身就可以被外部过渡捕获并添加动画效果,同时防止内部范围的内容被捕获。

<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;
}

或者,您也可以允许外部过渡捕获子级。为了保留剪裁效果,请使用嵌套视图过渡组,在封装容器的边界处剪裁捕获的子元素。不过,在这种情况下,您需要手动将 clip 应用于生成的 ::view-transition-group-children() 伪类。

.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 节点身份。
  • 强制性要求:通过在 ::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,因为选择不自行参与会停用自动溢出剪裁。

后备策略

如果您的基准目标不支持元素级视图过渡,请立即运行 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] 元素。