Transições de componentes com escopo

Iniciar uma transição de visualização com document.startViewTransition() anexa a sobreposição ::view-transition ao elemento :root, o que bloqueia eventos de ponteiro no restante da página, impede transições simultâneas e oculta cabeçalhos position: fixed ou elementos [popover] abertos (mesmo quando grupos de transição de visualização aninhados são usados para evitar o vazamento de overflow).

Chamar element.startViewTransition() em uma raiz de componente resolve isso ao definir o escopo da transição para essa subárvore do DOM: a árvore de pseudoelementos ::view-transition é injetada diretamente no elemento, preservando os contextos de empilhamento externo e a interatividade da página, além de permitir que vários componentes façam a transição simultaneamente.

Implementação

1. Acionar uma transição com escopo

Chame element.startViewTransition() no elemento de contêiner raiz do componente em vez de document.startViewTransition(). O elemento em que você chama startViewTransition() se torna o escopo da transição (o elemento de escopo): apenas os elementos na subárvore dele são capturados, apenas a renderização do escopo é interrompida durante o callback de atualização, e a sobreposição ::view-transition resultante corresponde ao tamanho, à forma e ao contexto de empilhamento do elemento de escopo.

2. Nomear elementos participantes

Atribua um view-transition-name aos elementos filhos dentro do escopo que precisa ser animado. É possível usar view-transition-name: match-element em itens de lista dinâmicos para derivar automaticamente o nome de transição de cada elemento da identidade do nó do DOM ou atribuir nomes explícitos (que só precisam ser exclusivos nesse escopo).

Os nomes são isolados em cada escopo, o que evita colisões entre componentes em diferentes partes do DOM. No entanto, os view-transition-names podem entrar em conflito com nomes em transições de visualização com escopo aninhadas. Para evitar isso, adicione view-transition-scope: all ao elemento de escopo filho.

3. Participação própria e corte de transbordamento

O elemento de escopo participa automaticamente da própria transição de visualização porque a folha de estilo do user agent aplica view-transition-name: root e view-transition-group: contain a ele. Dessa forma, quando a geometria do escopo é afetada pela mudança feita no DOM, ela anima suavemente a própria geometria com os filhos, como parte da transição de visualização.

Quando o escopo corta o estouro (overflow: hidden, scroll ou clip), o ::view-transition-group-children(root) aplica automaticamente o overflow: clip, impedindo que os filhos em transição ultrapassem os limites do componente.

4. Transições simultâneas e aninhadas

Subárvores de componentes separadas podem executar element.startViewTransition() simultaneamente sem pular umas às outras. Os escopos ativos recebem automaticamente view-transition-scope: all na folha de estilo do UA para que os valores internos de view-transition-name não vazem para as transições externas. Aplique view-transition-scope: all no CSS em contêineres de componentes repetidos para evitar que as transições externas capturem nomes internos, mesmo quando o componente estiver inativo.

Se uma transição externa precisar mover um componente enquanto a transição interna estiver ativa, atribua um view-transition-name a um elemento que envolve o escopo interno. A transição externa captura e anima apenas o wrapper, em vez do elemento de escopo interno, permitindo que a transição interna seja concluída.

Exemplo

Nenhum CSS é necessário no próprio elemento de escopo (.card ou .card ul) para estabelecer o escopo. Ele é determinado chamando startViewTransition() no elemento em vez de document. Se o componente for aninhado em uma transição de visualização externa, consulte Como executar uma transição 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();
    }
  });
}

Como executar uma transição externa

Ao iniciar uma transição de visualização fora do escopo pretendido (por exemplo, de um componente pai ou do documento para uma transição no nível da página), a transição externa vai capturar e animar o elemento do escopo e tudo o que estiver dentro dele.

Para evitar que isso aconteça, envolva o elemento de escopo em um elemento wrapper separado com o próprio view-transition-name e aplique view-transition-scope: all ao elemento de escopo interno. Isso permite que o próprio wrapper seja capturado e animado pela transição externa, impedindo que o conteúdo do escopo interno seja capturado.

<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, você pode permitir que a transição externa capture os filhos. Para manter os efeitos de corte, use grupos de transição de visualização aninhados para cortar os filhos capturados nos limites do wrapper. Nesse caso, no entanto, é necessário aplicar manualmente o clip ao pseudo ::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;
}

Restrições e acessibilidade

  • OBRIGATÓRIO: chame startViewTransition() no elemento raiz do componente em vez de document para que os elementos externos position: fixed e as sobreposições [popover] permaneçam em camadas na parte de cima e o restante da página continue interativo.
  • OBRIGATÓRIO: preserve e mova os nós DOM atuais (em vez de recriá-los com innerHTML) ao usar view-transition-name: match-element, já que match-element rastreia a identidade do nó DOM.
  • OBRIGATÓRIO: respeite as preferências do usuário de movimento reduzido usando @media (prefers-reduced-motion: reduce) ao definir animation: none !important em ::view-transition-group(*), ::view-transition-old(*) e ::view-transition-new(*).
  • OBRIGATÓRIO: preserve o foco do teclado ao remover um elemento em foco durante uma transição, mudando o foco para um item ou controle adjacente dentro do callback de atualização.
  • RECOMENDADO: use grupos de transição de visualização aninhados (view-transition-group: contain e overflow: clip em ::view-transition-group-children()) quando um elemento capturado dentro do escopo cortar o próprio estouro e tiver elementos capturados aninhados.
  • FAÇA: anexe transition.ready.catch(() => {}) quando interações rápidas puderem acionar novamente element.startViewTransition() no mesmo elemento de escopo.
  • RECOMENDAÇÃO: envolva um elemento de escopo interno em um elemento wrapper nomeado separado se uma transição externa precisar animar a posição do componente enquanto uma transição interna estiver em execução.
  • NÃO: defina view-transition-name: none em um elemento de escopo que corte o estouro (overflow: hidden, scroll ou clip) ou mude a própria geometria, já que desativar a autoparticipação desativa o corte automático de estouro.

Estratégias de fallback

Se o destino Baseline não for compatível com transições de visualização com escopo de elemento, faça uma degradação suave executando o callback de atualização do DOM imediatamente sem animação. Embora seja possível usar document.startViewTransition(), isso não é recomendado para atualizações de componentes isolados, já que uma transição com escopo de documento bloqueia transições de componentes simultâneas e oculta os elementos position: fixed e [popover] na camada 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(() => {});
}

Se você realmente precisar de um substituto usando document.startViewTransition(), verifique se os efeitos de corte dos contêineres são preservados, conforme detalhado na seção Como executar uma transição externa. Essa abordagem de substituição impede a execução de várias transições de visualização ao mesmo tempo e oculta os elementos position: fixed e [popover] na camada superior.