Transições de visualização do mesmo documento para aplicativos de página única

Publicado em: 17 de agosto de 2021. Última atualização: 25 de setembro de 2024

Quando uma transição de visualização é executada em um único documento, ela é chamada de transição de visualização no mesmo documento. Esse é o caso em aplicativos de página única (SPAs), em que o JavaScript é usado para atualizar o DOM. As transições de visualização no mesmo documento são compatíveis com o Chrome a partir da versão 111.

Para acionar uma transição de visualização no mesmo documento, chame document.startViewTransition:

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

  // With a View Transition:
  document.startViewTransition(() => updateTheDOMSomehow());
}

Quando invocado, o navegador captura automaticamente snapshots de todos os elementos que têm uma propriedade CSS view-transition-name declarada neles.

Em seguida, ele executa o callback transmitido que atualiza o DOM e faz snapshots do novo estado.

Em seguida, esses snapshots são organizados em uma árvore de pseudoelementos e animados usando o poder das animações CSS. Pares de snapshots do estado antigo e novo fazem uma transição suave da posição e do tamanho antigos para o novo local, enquanto o conteúdo faz um crossfade. Se quiser, use CSS para personalizar as animações.


A transição padrão: fade cruzado

A transição de visualização padrão é um crossfade, então ela serve como 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 maneira que você quiser. Por exemplo, é possível adicionar ou remover elementos, mudar nomes de classes ou alterar estilos.

E assim, as páginas fazem uma transição gradual:

O crossfade padrão. Demonstração mínima. Fonte.

Ok, um crossfade não é tão impressionante. Felizmente, as transições podem ser personalizadas, mas primeiro você precisa entender como esse crossfade básico funcionava.


Como essas transições funcionam

Vamos atualizar o exemplo de código anterior.

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

Quando .startViewTransition() é chamado, a API captura o estado atual da página. Isso inclui tirar um snapshot.

Quando a operação for concluída, o callback transmitido para .startViewTransition() será chamado. É aí que o DOM é alterado. Em seguida, a API captura o novo estado da página.

Depois que o novo estado é capturado, a API cria uma árvore de pseudoelementos assim:

::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, acima de todo o resto na página. Isso é útil se você quiser definir uma cor de plano 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 em tempo real da nova visualização. Ambos são renderizados como "conteúdo substituído" do CSS (como um <img>).

A visualização antiga é animada de opacity: 1 para opacity: 0, enquanto a nova é animada de opacity: 0 para opacity: 1, criando um efeito de fading cruzado.

Toda a animação é feita usando animações CSS, então elas podem ser personalizadas com CSS.

Personalizar a transição

Todos os pseudoelementos de transição de visualização podem ser segmentados com CSS. Como as animações são definidas usando CSS, é possível modificá-las usando as propriedades de animação CSS atuais. Exemplo:

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

Com essa mudança, o efeito de fade agora é muito lento:

Fundido cruzado longo. Demonstração mínima. Fonte.

Ok, isso ainda não é impressionante. Em vez disso, o código a seguir implementa 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. Fonte.

Fazer a transição de vários elementos

Na demonstração anterior, toda a página está envolvida na transição de eixo compartilhado. Isso funciona para a maior parte da página, mas não parece muito certo para o cabeçalho, já que ele desliza para fora e volta a deslizar para dentro.

Para evitar isso, extraia o cabeçalho do restante 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á um nome de transição. Ele é usado para identificar o elemento de maneira exclusiva durante a transição.

E o resultado disso:

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

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

Essa declaração CSS fez com que a árvore de pseudoelementos mudasse:

::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. Eles podem ser segmentados de forma independente com CSS e receber transições diferentes. No entanto, neste caso, main-header foi deixado com a transição padrão, que é um crossfade.

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

  • Posicionar e transformar (usando um transform)
  • Largura
  • Altura

Isso não importava até agora, já que o cabeçalho tem o mesmo tamanho e posição nos dois lados da mudança do DOM. Mas também é possível extrair o texto no cabeçalho:

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

fit-content é usado para que o elemento tenha o 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, em vez de manter o mesmo tamanho nas duas páginas.

Agora temos três partes para trabalhar:

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

Mas, de novo, vamos usar os padrões:

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

Agora, o texto do cabeçalho desliza um pouco para o lado, abrindo espaço para o botão "Voltar".


Animar vários pseudoelementos da mesma forma com view-transition-class

Browser Support

  • Chrome: 125.
  • Edge: 125.
  • Firefox Technology Preview: supported.
  • Safari: 18.2.

Source

Suponha que você tenha uma transição de visualização com vários cards, mas também um título na página. Para animar todos os cards, exceto o título, é necessário escrever um seletor que tenha como destino cada card individual.

h1 {
    view-transition-name: title;
}
::view-transition-group(title) {
    animation-timing-function: ease-in-out;
}

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }

#card20 { view-transition-name: card20; }

::view-transition-group(card1),
::view-transition-group(card2),
::view-transition-group(card3),
::view-transition-group(card4),

::view-transition-group(card20) {
    animation-timing-function: var(--bounce);
}

Tem 20 elementos? São 20 seletores que você precisa escrever. Adicionar um novo elemento? Em seguida, você também precisa aumentar o seletor que aplica os estilos de animação. Não é exatamente escalonável.

O view-transition-class pode ser usado nos pseudoelementos de transição de visualização para aplicar a mesma regra de estilo.

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
#card5 { view-transition-name: card5; }

#card20 { view-transition-name: card20; }

#cards-wrapper > div {
  view-transition-class: card;
}
html::view-transition-group(.card) {
  animation-timing-function: var(--bounce);
}

O exemplo de cards a seguir usa o snippet de CSS anterior. Todos os cards, incluindo os recém-adicionados, recebem a mesma aplicação de tempo com um seletor: html::view-transition-group(.card).

Gravação da demonstração de cards. Usando view-transition-class, ele aplica o mesmo animation-timing-function a todos os cartões, exceto os adicionados ou removidos.

Depurar transições

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

Usando o painel Animações, é possível pausar a próxima animação e, em seguida, avançar e voltar por ela. Durante esse processo, os pseudoelementos de transição podem ser encontrados no painel Elementos.

Depuração de transições de visualização com o Chrome DevTools.

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, são o mesmo elemento antes e depois da mudança no DOM, mas é possível criar transições em que isso não acontece.

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

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

Em seguida, quando a miniatura for clicada, 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();
  });
};

Resultado:

Um elemento fazendo a transição para outro. Demonstração mínima. Fonte.

A miniatura agora faz a transição 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 dessa transição é um pouco mais complicado do que o exemplo anterior, já que também processa a transição de volta para a página de miniaturas. Confira a origem para ver a implementação completa.


Transições de entrada e saída personalizadas

Confira este exemplo:

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

A barra lateral faz parte da transição:

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

Mas, ao contrário do cabeçalho no 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 vão aparecer 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 vai aparecer. Como não há uma imagem "antiga" para a barra lateral, o par de imagens terá apenas um ::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 anterior, a barra lateral faz a transição de maneira diferente, dependendo se está entrando, saindo ou presente nos dois estados. Ele entra deslizando da direita e aparecendo, sai deslizando para a direita e desaparecendo, e permanece no lugar quando está presente nos dois estados.

Para criar transições de entrada e saída específicas, use a pseudoclasse :only-child para segmentar os pseudoelementos antigos ou novos quando eles forem os únicos filhos 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 está presente nos dois estados, já que o padrão é perfeito.

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

O callback transmitido para .startViewTransition() pode retornar uma promessa, o 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 seja cumprida. Durante esse período, a página fica congelada, então os atrasos precisam ser mínimos. Especificamente, as buscas de rede precisam ser feitas antes de chamar .startViewTransition(), enquanto a página ainda está totalmente interativa, em vez de fazê-las como parte do callback .startViewTransition().

Se você decidir esperar que as imagens ou fontes estejam 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 o atraso e usar o conteúdo que você já tem.


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

No caso em que a miniatura faz a transição para uma imagem maior:

A miniatura fazendo a transição para uma imagem maior. Teste o site de demonstração.

A transição padrão é o crossfade, o que significa que a miniatura pode estar fazendo crossfade com uma imagem completa ainda não carregada.

Uma maneira de lidar com isso é esperar que a imagem completa seja carregada antes de iniciar a transição. O ideal é que isso seja feito antes de chamar .startViewTransition() para que a página permaneça interativa e um spinner possa ser mostrado para indicar ao usuário que as coisas estão carregando. Mas, nesse 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;
}

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

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

Processar mudanças na proporção

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 fazendo a transição para outro, com uma mudança na proporção. Demonstração mínima. Fonte.

Na transição padrão, o grupo é animado do tamanho anterior para o posterior. As visualizações antiga e nova têm 100% da 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. Então:

::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 aumenta, mas a imagem completa é "desrecortada" à medida que faz a transição de 1:1 para 16:9.

Para mais informações, confira Transições de visualização: como processar mudanças na proporção.


Usar consultas de mídia para mudar as transições de diferentes estados de dispositivos

Você pode usar transições diferentes em dispositivos móveis e computadores. Por exemplo, este exemplo faz um slide completo da lateral em dispositivos móveis, mas um slide mais sutil em computadores:

Um elemento fazendo a transição para outro. Demonstração mínima. Fonte.

Isso pode ser feito 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;
  }
}

Talvez você também queira mudar os elementos a que atribui um view-transition-name, dependendo das consultas de mídia correspondentes.


Reagir à preferência de "movimento reduzido"

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

Você pode 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 quer nenhum movimento. Em vez do snippet anterior, você pode escolher uma animação mais sutil, mas que ainda expresse a relação entre os elementos e o fluxo de dados.


Processar vários estilos de transição de visualização com tipos de transição de visualização

Browser Support

  • Chrome: 125.
  • Edge: 125.
  • Firefox Technology Preview: supported.
  • Safari: 18.

Source

Às vezes, uma transição de uma visualização específica para outra precisa ser personalizada. Por exemplo, ao ir para a próxima ou para a página anterior em uma sequência de paginação, talvez você queira deslizar o conteúdo em uma direção diferente, dependendo se você está indo para uma página mais alta ou mais baixa da sequência.

Gravação da demonstração de paginação. Ele usa transições diferentes dependendo da página para onde você está indo.

Para isso, use tipos de transição de visualização, que permitem atribuir um ou mais tipos a uma transição de visualização ativa. Por exemplo, ao passar para uma página mais alta em uma sequência de paginação, use o tipo forwards e, ao ir para uma página mais baixa, use o tipo backwards. Esses tipos só ficam ativos ao capturar ou realizar uma transição, e cada um pode ser personalizado com CSS para usar animações diferentes.

Para usar tipos em uma transição de visualização no mesmo documento, transmita types para o método startViewTransition. Para permitir isso, document.startViewTransition também aceita um objeto: update é a função de callback que atualiza o DOM, e types é uma matriz com os tipos.

const direction = determineBackwardsOrForwards();

const t = document.startViewTransition({
  update: updateTheDOMSomehow,
  types: ['slide', direction],
});
.

Para responder a esses tipos, use o seletor :active-view-transition-type(). Transmita o type que você quer segmentar para o seletor. Isso permite manter os estilos de várias transições de visualização separados uns dos outros, sem que as declarações de um interfiram nas declarações do outro.

Como os tipos só se aplicam ao capturar ou realizar a transição, você pode usar o seletor para definir ou cancelar a definição de um view-transition-name em um elemento apenas para a transição de visualização com esse tipo.

/* Determine what gets captured when the type is forwards or backwards */
html:active-view-transition-type(forwards, backwards) {
  :root {
    view-transition-name: none;
  }
  article {
    view-transition-name: content;
  }
  .pagination {
    view-transition-name: pagination;
  }
}

/* Animation styles for forwards type only */
html:active-view-transition-type(forwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-left;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-right;
  }
}

/* Animation styles for backwards type only */
html:active-view-transition-type(backwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-right;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-left;
  }
}

/* Animation styles for reload type only (using the default root snapshot) */
html:active-view-transition-type(reload) {
  &::view-transition-old(root) {
    animation-name: fade-out, scale-down;
  }
  &::view-transition-new(root) {
    animation-delay: 0.25s;
    animation-name: fade-in, scale-up;
  }
}

Na demonstração de paginação a seguir, o conteúdo da página desliza para frente ou para trás com base no número da página para que você está navegando. Os tipos são determinados no clique em que são transmitidos para document.startViewTransition.

Para segmentar qualquer transição de visualização ativa, independente do tipo, use o pseudoseletor de classe :active-view-transition.

html:active-view-transition {
    
}

Processar vários estilos de transição de visualização com um nome de classe na raiz da transição de visualização

Às vezes, uma transição de um tipo específico de visualização para outro precisa ser feita de forma personalizada. Ou, uma navegação "voltar" precisa ser diferente de uma navegação "avançar".

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

Antes dos tipos de transição, a maneira de lidar com esses casos era definir temporariamente um nome de classe na raiz da transição. Ao chamar document.startViewTransition, essa raiz de transição é o elemento <html>, acessível usando document.documentElement em JavaScript:

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

Para remover as classes depois que a transição terminar, 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 mudar 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.


Executar transições sem congelar outras animações

Confira esta demonstração de um vídeo mudando de posição:

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

Você viu algo errado? Não se preocupe se não tiver feito isso. Aqui ele está bem mais lento:

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

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

É possível corrigir isso, mas primeiro pergunte a si mesmo se vale a pena. Se você não notou o "problema" quando a transição estava sendo reproduzida na velocidade normal, não precisa mudar nada.

Se você realmente quiser corrigir, não mostre o ::view-transition-old(video). Mude direto para o ::view-transition-new(video). Para isso, substitua os estilos e 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. Fonte.

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


Integração com a API Navigation (e outros frameworks)

As transições de visualização são especificadas de forma que possam ser integradas a outros frameworks ou bibliotecas. Por exemplo, se o aplicativo de página única (SPA) estiver usando um roteador, ajuste o mecanismo de atualização dele para atualizar o conteúdo usando uma transição de visualização.

No snippet de código a seguir, extraído desta demonstração de paginação, o processador de interceptação da API Navigation é ajustado para chamar document.startViewTransition quando as transições de visualização são compatíveis.

navigation.addEventListener("navigate", (e) => {
    // Don't intercept if not needed
    if (shouldNotIntercept(e)) return;

    // Intercept the navigation
    e.intercept({
        handler: async () => {
            // Fetch the new content
            const newContent = await fetchNewContent(e.destination.url, {
                signal: e.signal,
            });

            // The UA does not support View Transitions, or the UA
            // already provided a Visual Transition by itself (e.g. swipe back).
            // In either case, update the DOM directly
            if (!document.startViewTransition || e.hasUAVisualTransition) {
                setContent(newContent);
                return;
            }

            // Update the content using a View Transition
            const t = document.startViewTransition(() => {
                setContent(newContent);
            });
        }
    });
});

Alguns navegadores, mas não todos, oferecem uma transição própria quando o usuário desliza o dedo para navegar. Nesse caso, não acione sua própria transição de visualização, porque isso levaria a uma experiência do usuário ruim ou confusa. O usuário veria duas transições, uma fornecida pelo navegador e outra por você, sendo executadas em sucessão.

Portanto, é recomendável evitar que uma transição de visualização seja iniciada quando o navegador já tiver fornecido a própria transição visual. Para fazer isso, verifique o valor da propriedade hasUAVisualTransition da instância NavigateEvent. A propriedade é definida como true quando o navegador fornece uma transição visual. Essa propriedade hasUIVisualTransition também existe em instâncias de PopStateEvent.

No snippet anterior, a verificação que determina se a transição de visualização será executada considera essa propriedade. Quando não há suporte para transições de visualização no mesmo documento ou quando o navegador já forneceu a própria transição, a transição de visualização é ignorada.

if (!document.startViewTransition || e.hasUAVisualTransition) {
  setContent(newContent);
  return;
}

Na gravação a seguir, o usuário desliza para voltar à página anterior. A captura à esquerda não inclui uma verificação da flag hasUAVisualTransition. A gravação à direita inclui a verificação, ignorando a transição de visualização manual porque o navegador forneceu uma transição visual.

Comparação do mesmo site sem (esquerda) e com (direita) uma verificação de hasUAVisualTransition

Como criar animações com JavaScript

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

Transição circular. Demonstração mínima. Fonte.

Algumas partes dessa transição não podem ser feitas 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)',
      }
    );
  });
}

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


Transições como uma melhoria

A API View Transition foi projetada para "encapsular" uma mudança no DOM e criar uma transição para ela. No entanto, a transição precisa ser tratada como um aprimoramento. Ou seja, o app não pode entrar em um estado de "erro" se a mudança 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 pode prejudicar o restante da experiência do usuário.

Para tratar as transições como um aprimoramento, não use promessas de transição de uma forma que faça o app gerar uma exceção 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 rejeitar se a transição não conseguir alcançar um estado ready, mas isso não significa que a troca de visualização falhou. O DOM pode ter sido atualizado, mas havia view-transition-names duplicados, então a transição foi ignorada.

Em vez disso:

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 rejeita mais se a transição falhar. Ele é resolvido quando a atualização do DOM é concluída e rejeitado se falhar.

Se você quiser que switchView seja resolvido quando a nova visualização estiver "estabilizada", ou seja, quando qualquer transição animada for concluída ou ignorada até o final, substitua transition.updateCallbackDone por transition.finished.


Não é um polyfill, mas…

Não é fácil fazer um polyfill desse recurso. No entanto, essa função auxiliar facilita muito as coisas em navegadores que não oferecem suporte a transições de visualização:

function transitionHelper({
  skipTransition = false,
  types = [],
  update,
}) {

  const unsupported = (error) => {
    const updateCallbackDone = Promise.resolve(update()).then(() => {});

    return {
      ready: Promise.reject(Error(error)),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
      types,
    };
  }

  if (skipTransition || !document.startViewTransition) {
    return unsupported('View Transitions are not supported in this browser');
  }

  try {
    const transition = document.startViewTransition({
      update,
      types,
    });

    return transition;
  } catch (e) {
    return unsupported('View Transitions with types are not supported in this browser');
  }
}

E ele pode ser usado assim:

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

  const transition = transitionHelper({
    update() {
      updateTheDOMSomehow(data);
    },
    types,
  });

  // …
}

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 da transição dependendo do tipo de navegação.

Você também pode transmitir true para skipTransition se não quiser uma animação, mesmo em navegadores que oferecem suporte a transições de visualização. Isso é útil se o site tiver uma preferência do usuário para desativar as transições.


Como trabalhar com frameworks

Se você estiver trabalhando com uma biblioteca ou um framework que abstrai as mudanças no DOM, a parte difícil é saber quando a mudança no DOM é concluída. Confira um conjunto de exemplos, usando o ajudante acima, em várias estruturas.

  • React: 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 ela é adequada neste caso. Como de costume com React e 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 é concluída quando o DOM é atualizado.
  • Svelte: muito semelhante ao Vue, mas o método para aguardar a próxima mudança é tick.
  • Lit: a chave aqui é a promessa this.updateComplete nos componentes, que é cumprida quando o DOM é atualizado.
  • Angular: a chave aqui é applicationRef.tick, que libera mudanças pendentes no DOM. A partir da versão 17 do Angular, é possível usar o withViewTransitions que vem com o @angular/router.

Referência da API

const viewTransition = document.startViewTransition(update)

Inicie um novo ViewTransition.

update é uma função chamada quando o estado atual do documento é capturado.

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

const viewTransition = document.startViewTransition({ update, types })

Inicia um novo ViewTransition com os tipos especificados.

update é chamado quando o estado atual do documento é capturado.

types define os tipos ativos para a transição ao capturar ou realizar a transição. Ele fica vazio inicialmente. Consulte viewTransition.types mais abaixo para mais informações.

Membros da instância de ViewTransition:

viewTransition.updateCallbackDone

Uma promessa que é cumprida quando a promessa retornada por updateCallback é cumprida ou rejeitada quando ela é rejeitada.

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

viewTransition.ready

Uma promessa que é cumprida quando os pseudoelementos da transição são criados e a animação está prestes a começar.

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

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

viewTransition.finished

Uma promessa que é cumprida quando o estado final está totalmente visível e interativo para o usuário.

Ele só rejeita se updateCallback retornar uma promessa rejeitada, o que indica que o estado final não foi criado.

Caso contrário, se uma transição não começar ou for ignorada durante a transição, o estado final ainda será alcançado, e finished será atendida.

viewTransition.types

Um objeto semelhante a Set que contém os tipos de transição de visualização ativa. Para manipular as entradas, use os métodos de instância clear(), add() e delete().

Para responder a um tipo específico em CSS, use o seletor de pseudoclasse :active-view-transition-type(type) na raiz da transição.

Os tipos são limpos automaticamente quando a transição de visualização termina.

viewTransition.skipTransition()

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

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


Referência de estilo e transição padrão

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

Posicionamento absoluto.

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

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

::view-transition-image-pair

Posicionamento absoluto para preencher o grupo.

Tem isolation: isolate para limitar o efeito do mix-blend-mode nas visualizações antigas e novas.

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

Posicionamento absoluto no canto superior esquerdo do wrapper.

Preenche 100% da largura do grupo, mas tem uma altura automática. Portanto, ele mantém a proporção em vez de preencher o grupo.

Tem mix-blend-mode: plus-lighter para permitir um verdadeiro crossfade.

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 dos desenvolvedores é sempre bem-vindo. Para isso, registre um problema com o grupo de trabalho do CSS no GitHub com sugestões e perguntas. Adicione o prefixo [css-view-transitions] ao seu problema.

Se você encontrar um bug, registre um bug do Chromium.