Iniciar una transición de vista con document.startViewTransition() adjunta la superposición ::view-transition al elemento :root, lo que bloquea los eventos de puntero en el resto de la página, evita las transiciones simultáneas y oculta los encabezados position: fixed o los elementos [popover] abiertos (incluso cuando se usan grupos de transiciones de vista anidados para evitar el desbordamiento).
Llamar a element.startViewTransition() en la raíz de un componente soluciona este problema, ya que limita la transición a ese subárbol del DOM: el árbol de pseudoelementos ::view-transition se inserta directamente en el elemento, lo que conserva los contextos de apilamiento externos y la interactividad de la página, a la vez que permite que varios componentes realicen transiciones de forma simultánea.
Implementación
1. Cómo activar una transición con alcance
Llama a element.startViewTransition() en el elemento contenedor raíz del componente en lugar de document.startViewTransition(). El elemento en el que llamas a startViewTransition() se convierte en el alcance de la transición (el elemento de alcance): solo se capturan los elementos dentro de su subárbol, solo se detiene la renderización del alcance durante la devolución de llamada de actualización y la superposición de ::view-transition resultante coincide con el tamaño, la forma y el contexto de apilamiento del elemento de alcance.
2. Elementos participantes del nombre
Asigna un view-transition-name a los elementos secundarios dentro del alcance que se deben animar. Puedes usar view-transition-name: match-element en elementos de lista dinámicos para derivar automáticamente el nombre de transición de cada elemento a partir de su identidad de nodo del DOM o asignar nombres explícitos (que solo deben ser únicos dentro de ese alcance).
Los nombres se aíslan dentro de cada alcance, lo que evita colisiones entre componentes en diferentes partes del DOM. Sin embargo, los view-transition-name pueden colisionar con los nombres en las transiciones de vistas con alcance anidado. Para evitarlo, agrega view-transition-scope: all al elemento de alcance secundario.
3. Recorte de participación propia y desbordamiento
El elemento de alcance participa automáticamente en su propia transición de vista porque la hoja de estilo del usuario-agente le aplica view-transition-name: root y view-transition-group: contain. De esta manera, cuando la geometría del alcance se vea afectada por el cambio que realices en el DOM, animará sin problemas su propia geometría junto con sus elementos secundarios, como parte de la transición de vistas.
Cuando el alcance recorta su desbordamiento (overflow: hidden, scroll o clip), ::view-transition-group-children(root) aplica automáticamente overflow: clip, lo que evita que los elementos secundarios en transición se salgan de los límites del componente.
4. Transiciones simultáneas y anidadas
Los subárboles de componentes separados pueden ejecutar element.startViewTransition() de forma simultánea sin omitirse entre sí. Los alcances activos reciben automáticamente view-transition-scope: all en la hoja de estilo del UA para que los valores internos de view-transition-name no se filtren en las transiciones externas. Aplica view-transition-scope: all en CSS en contenedores de componentes repetidos para evitar que las transiciones externas capturen nombres internos, incluso cuando el componente esté inactivo.
Si una transición externa necesita mover un componente mientras su transición interna está activa, asigna un view-transition-name a un elemento que una el alcance interno. Luego, la transición externa captura y anima solo el wrapper en lugar del elemento de alcance interno, lo que permite que la transición interna se ejecute hasta completarse.
Ejemplo
No se necesita CSS en el elemento de alcance en sí (.card o .card ul) para establecer el alcance. El alcance se determina llamando a startViewTransition() en el elemento en lugar de document (si el componente se anidará dentro de una transición de vista externa, consulta Cómo ejecutar una transición externa para 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();
}
});
}
Ejecuta una transición externa
Cuando se inicia una transición de vista fuera del alcance previsto (p.ej., desde un componente superior o desde el documento para una transición a nivel de la página), la transición externa capturará y animará el elemento de alcance y todo lo que contenga.
Para evitar que esto suceda, une el elemento de alcance en un elemento de wrapper separado con su propio view-transition-name y aplica view-transition-scope: all al elemento de alcance interno. Esto permite que la transición externa capture y anime el wrapper, a la vez que evita que se capture el contenido del alcance interno.
<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;
}
Como alternativa, puedes permitir que la transición externa capture los elementos secundarios. Para conservar los efectos de recorte, recurre al uso de grupos de transiciones de vistas anidadas para recortar los elementos secundarios capturados en los límites de su wrapper. Sin embargo, en ese caso, debes aplicar manualmente el clip en el seudoelemento ::view-transition-group-children() resultante.
.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;
}
Restricciones y accesibilidad
- OBLIGATORIO: Llama a
startViewTransition()en el elemento raíz del componente en lugar dedocumentpara que los elementosposition: fixedexternos y las superposiciones de[popover]permanezcan en capas en la parte superior y el resto de la página siga siendo interactivo. - OBLIGATORIO: Conserva y mueve los nodos del DOM existentes (en lugar de volver a crearlos con
innerHTML) cuando usesview-transition-name: match-element, ya quematch-elementhace un seguimiento de la identidad de los nodos del DOM. - OBLIGATORIO: Respeta las preferencias del usuario para reducir el movimiento con
@media (prefers-reduced-motion: reduce)configurandoanimation: none !importanten::view-transition-group(*),::view-transition-old(*)y::view-transition-new(*). - OBLIGATORIO: Conserva el enfoque del teclado cuando quites un elemento enfocado durante una transición. Para ello, cambia el enfoque a un elemento o control adyacente dentro de la devolución de llamada de actualización.
- SÍ: Usa grupos de transiciones de vistas anidadas (
view-transition-group: containyoverflow: clipen::view-transition-group-children()) cuando un elemento capturado dentro del alcance recorte su propio desbordamiento y contenga elementos capturados anidados. - SÍ: Adjunta
transition.ready.catch(() => {})cuando las interacciones rápidas puedan volver a activarelement.startViewTransition()en el mismo elemento de alcance. - SÍ: Une un elemento de alcance interno en un elemento de wrapper con nombre independiente si una transición externa necesita animar la posición del componente mientras se ejecuta una transición interna.
- NO: Establezcas
view-transition-name: noneen un elemento de alcance que recorte su desbordamiento (overflow: hidden,scrolloclip) o cambie su propia geometría, ya que inhabilitar la participación propia inhabilita el recorte automático de desbordamiento.
Estrategias de resguardo
Si tu objetivo de Baseline no admite transiciones de vistas con alcance de elemento, degrada de forma elegante ejecutando la devolución de llamada de actualización del DOM de inmediato sin animación. Si bien puedes recurrir a document.startViewTransition(), no se recomienda para las actualizaciones de componentes aislados, ya que una transición con alcance de documento bloquea las transiciones de componentes simultáneas y oculta los elementos position: fixed y [popover] en la capa superior.
// 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(() => {});
}
Si realmente necesitas tener un resguardo con document.startViewTransition(), deberás asegurarte de que se conserven los efectos de recorte de los contenedores, como se detalla en la sección Cómo ejecutar una transición externa. Ten en cuenta que este enfoque de resguardo interrumpe la capacidad de ejecutar varias transiciones de vistas de forma simultánea y ocultará los elementos position: fixed y [popover] en la capa superior.