Transições simples e suaves com a API View Transitions

Jake Archibald
Jake Archibald

Compatibilidade com navegadores

  • 111
  • 111
  • x
  • x

Origem

A API View Transition facilita a mudança do DOM em uma única etapa, criando uma transição animada entre os dois estados. Ela está disponível no Chrome 111 e em versões mais recentes.

Transições criadas com a API View Transition. Teste o site de demonstração: é necessário ter o Chrome 111 ou mais recente.

Por que precisamos desse recurso?

As transições de página não só têm uma ótima aparência, como também comunicam a direção do fluxo e deixam claro quais elementos estão relacionados de página para página. Eles podem até acontecer durante a busca de dados, levando a uma percepção de desempenho mais rápida.

Mas já temos ferramentas de animação na Web, como transições CSS, animações CSS e a API Web Animation. Então, por que precisamos de algo novo para mover as coisas?

A verdade é que as transições de estado são difíceis, mesmo com as ferramentas que já temos.

Até mesmo algo como um simples cross-fade envolve a presença dos dois estados ao mesmo tempo. Isso apresenta desafios de usabilidade, como lidar com interações adicionais no elemento de saída. Além disso, para usuários de dispositivos assistivos, há um período em que os estados antes e depois ficam no DOM ao mesmo tempo, e as coisas podem se mover pela árvore de maneira visualmente agradável, mas podem facilmente perder a posição de leitura e o foco.

Processar mudanças de estado é particularmente desafiador se os dois estados diferem na posição de rolagem. E, se um elemento estiver sendo movido de um contêiner para outro, você poderá ter dificuldades com overflow: hidden e outras formas de recorte, o que significa que é necessário reestruturar o CSS para ter o efeito desejado.

Não é impossível, é muito difícil.

As transições de visualização oferecem uma maneira mais fácil, permitindo que você faça a mudança do DOM sem qualquer sobreposição entre os estados, mas crie uma animação de transição entre os estados usando visualizações instantâneas.

Além disso, embora a implementação atual seja destinada a apps de página única (SPAs), esse recurso será expandido para permitir transições entre os carregamentos de página inteira, o que é impossível no momento.

Status da padronização

O recurso está sendo desenvolvido no Grupo de trabalho de CSS do W3C (link em inglês) como uma especificação rascunho.

Quando estivermos satisfeitos com o design da API, vamos iniciar os processos e as verificações necessárias para enviar esse recurso à versão estável.

O feedback do desenvolvedor é muito importante, então registre problemas no GitHub com sugestões e perguntas.

A transição mais simples: um cross-fade

A transição de visualizações padrão é um cross-fade, portanto, essa é uma boa introdução à API:

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // With a transition:
  document.startViewTransition(() => updateTheDOMSomehow(data));
}

Em que updateTheDOMSomehow muda o DOM para o novo estado. Isso pode ser feito da forma que você quiser: adicionar/remover elementos, mudar nomes de classes, mudar estilos... não importa.

Assim, as páginas fazem a transição cruzada:

O cross-fade padrão. Demonstração mínima. Origem.

Ok, um cross-fade não é tão impressionante. Felizmente, as transições podem ser personalizadas, mas, antes disso, precisamos entender como esse cross-fade básico funcionou.

Como essas transições funcionam

Usando o exemplo de código acima:

document.startViewTransition(() => updateTheDOMSomehow(data));

Quando .startViewTransition() é chamado, a API captura o estado atual da página. Isso inclui fazer uma captura de tela.

Depois disso, o callback transmitido para .startViewTransition() será chamado. É aqui que o DOM é alterado. Em seguida, a API captura o novo estado da página.

Depois que o estado é capturado, a API constrói uma árvore de pseudoelementos como esta:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

O ::view-transition fica em uma sobreposição, sobre todo o restante na página. Isso é útil se você deseja definir uma cor de fundo para a transição.

::view-transition-old(root) é uma captura de tela da visualização antiga, e ::view-transition-new(root) é uma representação ao vivo da nova visualização. Ambos são renderizados como "conteúdo substituído" de CSS (como um <img>).

A visualização antiga é animada de opacity: 1 para opacity: 0, enquanto a nova visualização é animada de opacity: 0 para opacity: 1, criando um cross-fade.

Toda a animação é realizada usando animações CSS, para que possam ser personalizadas com CSS.

Personalização simples

Todos os pseudoelementos acima podem ser segmentados com CSS e, como as animações são definidas com CSS, você pode modificá-las usando as propriedades de animação CSS existentes. Exemplo:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 5s;
}

Com essa mudança, a esmaecimento fica bem lenta:

Cross-fade longo. Demonstração mínima. Origem.

Ok, isso ainda não é impressionante. Em vez disso, vamos implementar a transição de eixo compartilhado do Material Design:

@keyframes fade-in {
  from { opacity: 0; }
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}

@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

E aqui está o resultado:

Transição de eixo compartilhado. Demonstração mínima. Origem.

Como fazer a transição de vários elementos

Na demonstração anterior, a página inteira estava envolvida na transição do eixo compartilhado. Isso funciona na maior parte da página, mas não parece correto para o título, já que ele desliza para fora para deslizar de novo.

Para evitar isso, você pode extrair o cabeçalho do resto da página para que ele possa ser animado separadamente. Isso é feito atribuindo um view-transition-name ao elemento.

.main-header {
  view-transition-name: main-header;
}

O valor de view-transition-name pode ser o que você quiser, exceto none, que significa que não há nome de transição. Ele é usado para identificar de forma exclusiva o elemento na transição.

E o resultado disso:

Transição de eixo compartilhado com cabeçalho fixo. Demonstração mínima. Origem.

Agora, o cabeçalho permanece no lugar e faz a transição gradual.

Essa declaração CSS fez a árvore de pseudoelementos mudar:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(main-header)
   └─ ::view-transition-image-pair(main-header)
      ├─ ::view-transition-old(main-header)
      └─ ::view-transition-new(main-header)

Agora há dois grupos de transição. Um para o cabeçalho e outro para o restante. Elas podem ser segmentadas de forma independente com o CSS e receber diferentes transições. No entanto, nesse caso, a main-header ficou com a transição padrão, que é um cross-fade.

A transição padrão não é apenas um cross-fade, o ::view-transition-group também faz a transição:

  • Posicionar e transformar (via transform)
  • Largura
  • Altura

Isso não era importante até agora, já que o cabeçalho tem o mesmo tamanho e posiciona os dois lados da mudança do DOM. Mas também podemos extrair o texto do cabeçalho:

.main-header-text {
  view-transition-name: main-header-text;
  width: fit-content;
}

fit-content é usado para que o elemento fique do tamanho do texto, em vez de se estender até a largura restante. Sem isso, a seta para trás reduz o tamanho do elemento de texto do cabeçalho, mas queremos que ele tenha o mesmo tamanho nas duas páginas.

Agora, temos três partes para testar:

::view-transition
├─ ::view-transition-group(root)
│  └─ …
├─ ::view-transition-group(main-header)
│  └─ …
└─ ::view-transition-group(main-header-text)
   └─ …

Mas vamos continuar com os valores padrão:

Texto do cabeçalho deslizante. Demonstração mínima. Origem.

Agora o texto do título desliza um pouco para deixar espaço para o botão "Voltar".

Como depurar transições

Como as transições de visualização são criadas com base nas animações CSS, o painel Animations no Chrome DevTools é ótimo para depurar transições.

No painel Animações, você pode pausar a próxima animação e depois deslizar para frente e para trás nela. Durante isso, os pseudoelementos de transição podem ser encontrados no painel Elementos.

Como depurar transições de visualizações com o Chrome Dev Tools

Os elementos de transição não precisam ser o mesmo elemento DOM

Até agora, usamos view-transition-name para criar elementos de transição separados para o cabeçalho e o texto nele. Conceitualmente, trata-se do mesmo elemento antes e depois da mudança do DOM, mas você pode criar transições quando isso não acontecer.

Por exemplo, a incorporação do vídeo principal pode receber um view-transition-name:

.full-embed {
  view-transition-name: full-embed;
}

Então, quando a miniatura receber um clique, ela poderá receber o mesmo view-transition-name, apenas durante a transição:

thumbnail.onclick = async () => {
  thumbnail.style.viewTransitionName = 'full-embed';

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = '';
    updateTheDOMSomehow();
  });
};

E o resultado:

Um elemento em transição para outro. Demonstração mínima. Origem.

A miniatura passa para a imagem principal. Embora sejam elementos conceitualmente (e literalmente) diferentes, a API de transição os trata como a mesma coisa, porque compartilham o mesmo view-transition-name.

O código real para isso é um pouco mais complicado do que o exemplo acima, já que também lida com a transição de volta para a página de miniatura. Consulte a origem para ver a implementação completa.

Transições personalizadas de entrada e saída

Confira este exemplo:

Entrada e saída da barra lateral. Demonstração mínima. Origem.

A barra lateral faz parte da transição:

.sidebar {
  view-transition-name: sidebar;
}

Mas, diferente do cabeçalho do exemplo anterior, a barra lateral não aparece em todas as páginas. Se os dois estados tiverem a barra lateral, os pseudoelementos de transição ficarão assim:

::view-transition
├─ …other transition groups…
└─ ::view-transition-group(sidebar)
   └─ ::view-transition-image-pair(sidebar)
      ├─ ::view-transition-old(sidebar)
      └─ ::view-transition-new(sidebar)

No entanto, se a barra lateral estiver apenas na nova página, o pseudoelemento ::view-transition-old(sidebar) não estará lá. Como não há uma imagem "antiga" na barra lateral, o par de imagens terá apenas uma ::view-transition-new(sidebar). Da mesma forma, se a barra lateral estiver apenas na página antiga, o par de imagens terá apenas um ::view-transition-old(sidebar).

Na demonstração acima, a barra lateral faz a transição de modo diferente, dependendo se está entrando, saindo ou presente nos dois estados. Ela entra deslizando da direita e desaparecendo, sai deslizando para a direita e desaparece e permanece no lugar quando está presente nos dois estados.

Para criar transições específicas de entrada e saída, use a pseudoclasse :only-child (link em inglês) para direcionar o pseudoelemento antigo/novo quando ele for o único filho no par de imagens:

/* Entry transition */
::view-transition-new(sidebar):only-child {
  animation: 300ms cubic-bezier(0, 0, 0.2, 1) both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Exit transition */
::view-transition-old(sidebar):only-child {
  animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

Nesse caso, não há uma transição específica para quando a barra lateral estiver presente nos dois estados, já que o padrão é perfeito.

Atualizações assíncronas do DOM e espera por conteúdo

O callback transmitido para .startViewTransition() pode retornar uma promessa, que permite atualizações assíncronas do DOM e espera que o conteúdo importante esteja pronto.

document.startViewTransition(async () => {
  await something;
  await updateTheDOMSomehow();
  await somethingElse;
});

A transição não será iniciada até que a promessa for atendida. Durante esse período, a página fica congelada. Por isso, o tempo de espera deve ser mínimo. Especificamente, as buscas de rede precisam ser feitas antes de chamar .startViewTransition(), enquanto a página ainda está totalmente interativa, em vez de fazer isso como parte do callback .startViewTransition().

Se você decidir esperar até que imagens ou fontes fiquem prontas, use um tempo limite agressivo:

const wait = ms => new Promise(r => setTimeout(r, ms));

document.startViewTransition(async () => {
  updateTheDOMSomehow();

  // Pause for up to 100ms for fonts to be ready:
  await Promise.race([document.fonts.ready, wait(100)]);
});

No entanto, em alguns casos, é melhor evitar totalmente esse atraso e usar o conteúdo que você já tem.

Aproveitar ao máximo o conteúdo que você já tem

No caso em que a miniatura muda para uma imagem maior:

A transição padrão é o esmaecimento cruzado, o que significa que a miniatura pode passar por esmaecimento cruzado com uma imagem completa ainda não carregada.

Uma maneira de lidar com isso é aguardar o carregamento da imagem completa antes de iniciar a transição. O ideal seria fazer isso antes de chamar .startViewTransition(), para que a página permaneça interativa, e um ícone de carregamento poderá ser mostrado para indicar ao usuário que as coisas estão sendo carregadas. Mas, neste caso, há uma maneira melhor:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
}

A miniatura não desaparece, ela fica apenas abaixo da imagem inteira. Isso significa que, se a nova visualização não tiver sido carregada, a miniatura vai ficar visível durante a transição. Isso significa que a transição pode começar imediatamente e que a imagem completa pode ser carregada no próprio tempo.

Isso não funcionaria se a nova visualização tivesse transparência, mas neste caso sabemos que não tem, então podemos fazer essa otimização.

Como processar mudanças na proporção

Convenientemente, todas as transições até agora foram para elementos com a mesma proporção, mas nem sempre será assim. E se a miniatura for 1:1 e a imagem principal for 16:9?

Um elemento em transição para outro, com uma mudança na proporção. Demonstração mínima. Origem.

Na transição padrão, o grupo é animado do tamanho anterior para o posterior. As visualizações antigas e novas têm 100% de largura do grupo e altura automática, o que significa que elas mantêm a proporção, independentemente do tamanho do grupo.

Esse é um bom padrão, mas não é o que queremos neste caso. Portanto:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
  /* Make the height the same as the group,
  meaning the view size might not match its aspect-ratio. */
  height: 100%;
  /* Clip any overflow of the view */
  overflow: clip;
}

/* The old view is the thumbnail */
::view-transition-old(full-embed) {
  /* Maintain the aspect ratio of the view,
  by shrinking it to fit within the bounds of the element */
  object-fit: contain;
}

/* The new view is the full image */
::view-transition-new(full-embed) {
  /* Maintain the aspect ratio of the view,
  by growing it to cover the bounds of the element */
  object-fit: cover;
}

Isso significa que a miniatura permanece no centro do elemento à medida que a largura se expande, mas a imagem completa é "descortada" à medida que muda de 1:1 para 16:9.

Mudar a transição dependendo do estado do dispositivo

Você pode usar transições diferentes em dispositivos móveis e em computadores, como neste exemplo, em que um slide completo é exibido na lateral no dispositivo móvel e um slide mais sutil no computador:

Um elemento em transição para outro. Demonstração mínima. Origem.

Isso pode ser conseguido usando consultas de mídia regulares:

/* Transitions for mobile */
::view-transition-old(root) {
  animation: 300ms ease-out both full-slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out both full-slide-from-right;
}

@media (min-width: 500px) {
  /* Overrides for larger displays.
  This is the shared axis transition from earlier in the article. */
  ::view-transition-old(root) {
    animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
  }

  ::view-transition-new(root) {
    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
  }
}

Também é possível mudar quais elementos você atribui a uma view-transition-name, dependendo das consultas de mídia correspondentes.

Como reagir à preferência de "movimento reduzido"

Os usuários podem indicar que preferem movimento reduzido pelo sistema operacional e que essa preferência é exposta pelo CSS.

Você pode optar por impedir qualquer transição para esses usuários:

@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

No entanto, uma preferência por "movimento reduzido" não significa que o usuário não quer nenhum movimento. Em vez do acima, você pode escolher uma animação mais sutil, mas que ainda expresse a relação entre os elementos e o fluxo de dados.

Mudar a transição dependendo do tipo de navegação

Às vezes, a navegação de um tipo específico de página para outro precisa ter uma transição adaptada especificamente. Ou uma navegação "voltar" é diferente de uma navegação "avançar".

Transições diferentes ao "voltar". Demonstração mínima. Origem.

A melhor maneira de lidar com esses casos é definir um nome de classe no <html>, também conhecido como elemento do documento:

if (isBackNavigation) {
  document.documentElement.classList.add('back-transition');
}

const transition = document.startViewTransition(() =>
  updateTheDOMSomehow(data)
);

try {
  await transition.finished;
} finally {
  document.documentElement.classList.remove('back-transition');
}

Este exemplo usa transition.finished, uma promessa que é resolvida quando a transição atinge o estado final. Outras propriedades desse objeto são abordadas na Referência da API.

Agora você pode usar esse nome de classe no seu CSS para alterar a transição:

/* 'Forward' transitions */
::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms
      cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Overrides for 'back' transitions */
.back-transition::view-transition-old(root) {
  animation-name: fade-out, slide-to-right;
}

.back-transition::view-transition-new(root) {
  animation-name: fade-in, slide-from-left;
}

Assim como nas consultas de mídia, a presença dessas classes também pode ser usada para mudar quais elementos recebem um view-transition-name.

Como fazer a transição sem congelar outras animações

Confira esta demonstração de uma posição em transição de vídeo:

Transição de vídeo. Demonstração mínima. Origem.

Você notou algo de errado? Não se preocupe se não foi. Aqui ela está mais lenta:

Transição de vídeo, mais lenta. Demonstração mínima. Origem.

Durante a transição, o vídeo parece congelar, e a versão em reprodução aparece gradualmente. Isso ocorre porque ::view-transition-old(video) é uma captura de tela da visualização antiga, enquanto ::view-transition-new(video) é uma imagem ativa da nova visualização.

Você pode corrigir isso, mas primeiro, pergunte a si mesmo se vale a pena corrigir isso. Se você não viu o "problema" quando a transição estava sendo reproduzida na velocidade normal, não mudaria.

Se você quiser corrigir o problema, não mostre a ::view-transition-old(video). Mude diretamente para a ::view-transition-new(video). Para fazer isso, substitua os estilos e as animações padrão:

::view-transition-old(video) {
  /* Don't show the frozen old view */
  display: none;
}

::view-transition-new(video) {
  /* Don't fade the new view in */
  animation: none;
}

Pronto.

Transição de vídeo, mais lenta. Demonstração mínima. Origem.

Agora, o vídeo é reproduzido durante a transição.

Animar com JavaScript

Até agora, todas as transições foram definidas usando CSS, mas às vezes o CSS não é suficiente:

Transição de círculo. Demonstração mínima. Origem.

Algumas partes dessa transição não podem ser realizadas apenas com CSS:

  • A animação começa no local do clique.
  • A animação termina com o círculo tendo um raio até o canto mais distante. No entanto, esperamos que isso seja possível com o CSS no futuro.

Felizmente, é possível criar transições usando a API Web Animation.

let lastClick;
addEventListener('click', event => (lastClick = event));

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // Get the click position, or fallback to the middle of the screen
  const x = lastClick?.clientX ?? innerWidth / 2;
  const y = lastClick?.clientY ?? innerHeight / 2;
  // Get the distance to the furthest corner
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y)
  );

  // With a transition:
  const transition = document.startViewTransition(() => {
    updateTheDOMSomehow(data);
  });

  // Wait for the pseudo-elements to be created:
  transition.ready.then(() => {
    // Animate the root's new view
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in',
        // Specify which pseudo-element to animate
        pseudoElement: '::view-transition-new(root)',
      }
    );
  });
}

Esse exemplo usa transition.ready, uma promessa que é resolvida quando os pseudoelementos da transição são criados. Outras propriedades desse objeto são abordadas na Referência da API.

Transições como um aprimoramento

A API View Transition foi projetada para "unir" uma mudança DOM e criar uma transição para ela. No entanto, a transição deve ser tratada como um aprimoramento, por exemplo, seu aplicativo não deverá entrar em um estado de "erro" se a alteração do DOM for bem-sucedida, mas a transição falhar. O ideal é que a transição não falhe, mas, se isso acontecer, ela não deve interromper o restante da experiência do usuário.

Para tratar as transições como uma melhoria, tome cuidado para não usar promessas de transição de uma forma que faça com que seu app seja gerado se a transição falhar.

O que não fazer
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  await transition.ready;

  document.documentElement.animate(
    {
      clipPath: [`inset(50%)`, `inset(0)`],
    },
    {
      duration: 500,
      easing: 'ease-in',
      pseudoElement: '::view-transition-new(root)',
    }
  );
}

O problema com esse exemplo é que switchView() vai ser rejeitada se a transição não alcançar um estado ready, mas isso não significa que a visualização falhou ao mudar. O DOM pode ter sido atualizado, mas havia view-transition-names duplicados, então a transição foi ignorada.

Como alternativa:

O que fazer
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  animateFromMiddle(transition);

  await transition.updateCallbackDone;
}

async function animateFromMiddle(transition) {
  try {
    await transition.ready;

    document.documentElement.animate(
      {
        clipPath: [`inset(50%)`, `inset(0)`],
      },
      {
        duration: 500,
        easing: 'ease-in',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  } catch (err) {
    // You might want to log this error, but it shouldn't break the app
  }
}

Este exemplo usa transition.updateCallbackDone para aguardar a atualização do DOM e rejeitar se ela falhar. O switchView não será mais rejeitado se a transição falhar. Ele será resolvido quando a atualização do DOM for concluída e será rejeitado se falhar.

Se você quiser que switchView resolva quando a nova visualização for "definida", como em qualquer transição animada concluída ou ignorada para o final, substitua transition.updateCallbackDone por transition.finished.

Não é um polyfill, mas...

Não acho que esse recurso possa ser usado com polyfill, mas fico feliz por provar que está errado.

No entanto, essa função auxiliar facilita muito esse processo em navegadores que não têm suporte a transições de visualização:

function transitionHelper({
  skipTransition = false,
  classNames = [],
  updateDOM,
}) {
  if (skipTransition || !document.startViewTransition) {
    const updateCallbackDone = Promise.resolve(updateDOM()).then(() => {});

    return {
      ready: Promise.reject(Error('View transitions unsupported')),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
    };
  }

  document.documentElement.classList.add(...classNames);

  const transition = document.startViewTransition(updateDOM);

  transition.finished.finally(() =>
    document.documentElement.classList.remove(...classNames)
  );

  return transition;
}

E ela pode ser usada assim:

function spaNavigate(data) {
  const classNames = isBackNavigation ? ['back-transition'] : [];

  const transition = transitionHelper({
    classNames,
    updateDOM() {
      updateTheDOMSomehow(data);
    },
  });

  // …
}

Em navegadores que não oferecem suporte a transições de visualização, updateDOM ainda será chamado, mas não haverá uma transição animada.

Você também pode fornecer alguns classNames para adicionar a <html> durante a transição, facilitando a mudança, dependendo do tipo de navegação.

Você também pode transmitir true para skipTransition se não quiser uma animação, mesmo em navegadores compatíveis com transições de visualizações. Isso será útil se o site tiver preferência por desativar as transições.

Como trabalhar com frameworks

Se você estiver trabalhando com uma biblioteca ou framework que abstraia as alterações do DOM, a parte complicada é saber quando a alteração do DOM foi concluída. Veja um conjunto de exemplos usando o auxiliar acima em vários frameworks.

  • Reagir: a chave aqui é flushSync, que aplica um conjunto de mudanças de estado de forma síncrona. Sim, há um grande aviso sobre o uso dessa API, mas Dan Abramov garante que é apropriado nesse caso. Como de costume com o React e o código assíncrono, ao usar as várias promessas retornadas por startViewTransition, verifique se o código está sendo executado com o estado correto.
  • Vue.js: a chave aqui é nextTick, que é atendida quando o DOM é atualizado.
  • Svelte: muito semelhante ao Vue, mas o método para aguardar a próxima mudança é tick (link em inglês).
  • Lit: a chave aqui é a promessa this.updateComplete nos componentes, que será atendida quando o DOM for atualizado.
  • Angular: chave aqui é applicationRef.tick, que limpa alterações pendentes do DOM. A partir da versão 17 do Angular, é possível usar withViewTransitions que vem com @angular/router.

Referência da API

const viewTransition = document.startViewTransition(updateCallback)

Inicie uma nova ViewTransition.

O updateCallback é chamado quando o estado atual do documento é capturado.

Depois, quando a promessa retornada por updateCallback for atendida, a transição vai começar no próximo frame. Se a promessa retornada por updateCallback for rejeitada, a transição será abandonada.

Membros da instância de ViewTransition:

viewTransition.updateCallbackDone

Uma promessa que será atendida quando a promessa retornada por updateCallback for atendida ou rejeitada quando for rejeitada.

A API View Transition encapsula uma mudança do DOM e cria uma transição. No entanto, às vezes você não se importa com o sucesso/fracasso da animação de transição, mas deseja saber se e quando a mudança do DOM acontece. updateCallbackDone é para esse caso de uso.

viewTransition.ready

Uma promessa que será atendida quando os pseudoelementos da transição forem criados e a animação estiver prestes a começar.

Ela será rejeitada se a transição não puder começar. Isso pode ocorrer devido a uma configuração incorreta, como view-transition-names duplicados ou se updateCallback retornar uma promessa recusada.

Isso é útil para animar os pseudoelementos de transição com JavaScript.

viewTransition.finished

Uma promessa que será atendida quando o estado final estiver totalmente visível e interativo para o usuário.

Ela só será rejeitada se updateCallback retornar uma promessa recusada, porque isso indica que o estado final não foi criado.

Caso contrário, se uma transição não for iniciada ou for ignorada durante a transição, o estado final ainda será atingido, então finished será atendido.

viewTransition.skipTransition()

Pule a parte de animação da transição.

Ele não pulará a chamada de updateCallback, já que a mudança do DOM é separada da transição.

Estilo padrão e referência de transição

::view-transition
O pseudoelemento raiz que preenche a janela de visualização e contém cada ::view-transition-group.
::view-transition-group

Absolutamente posicionado.

Transições width e height entre os estados "antes" e "depois".

Transições transform entre os quadrados do espaço da janela de visualização "antes" e "depois".

::view-transition-image-pair

Absolutamente posicionado para preencher o grupo.

Tem isolation: isolate para limitar o efeito do modo de combinação plus-lighter nas visualizações antiga e nova.

::view-transition-new e ::view-transition-old

Absolutamente posicionado no canto superior esquerdo do wrapper.

Preenche 100% da largura do grupo, mas tem uma altura automática para manter a proporção em vez de preencher o grupo.

Tem mix-blend-mode: plus-lighter para permitir um cross-fade real.

A visualização antiga faz a transição de opacity: 1 para opacity: 0. A nova visualização faz a transição de opacity: 0 para opacity: 1.

Feedback

O feedback do desenvolvedor é muito importante nesta etapa. Registre problemas no GitHub com sugestões e perguntas.