diff --git a/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md b/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md index 9b6095f8b..8b0e359da 100644 --- a/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md +++ b/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md @@ -2,26 +2,26 @@ title: "React Labs: View Transitions, Activity, and more" author: Ricky Hanlon date: 2025/04/23 -description: In React Labs posts, we write about projects in active research and development. In this post, we're sharing two new experimental features that are ready to try today, and updates on other areas we're working on now. +description: Nas postagens do React Labs, escrevemos sobre projetos em pesquisa e desenvolvimento ativo. Nesta postagem, estamos compartilhando dois novos recursos experimentais que estão prontos para serem testados hoje, e atualizações sobre outras áreas em que estamos trabalhando agora. --- -April 23, 2025 by [Ricky Hanlon](https://twitter.com/rickhanlonii) +23 de abril de 2025 por [Ricky Hanlon](https://twitter.com/rickhanlonii) --- -In React Labs posts, we write about projects in active research and development. In this post, we're sharing two new experimental features that are ready to try today, and updates on other areas we're working on now. +Nas postagens do React Labs, escrevemos sobre projetos em pesquisa e desenvolvimento ativo. Nesta postagem, estamos compartilhando dois novos recursos experimentais que estão prontos para teste hoje, e atualizações sobre outras áreas em que estamos trabalhando agora. -Today, we're excited to release documentation for two new experimental features that are ready for testing: +Hoje, estamos animados em lançar a documentação para dois novos recursos experimentais que estão prontos para teste: - [View Transitions](#view-transitions) - [Activity](#activity) -We're also sharing updates on new features currently in development: +Também estamos compartilhando atualizações sobre novos recursos atualmente em desenvolvimento: - [React Performance Tracks](#react-performance-tracks) - [Compiler IDE Extension](#compiler-ide-extension) - [Automatic Effect Dependencies](#automatic-effect-dependencies) @@ -30,65 +30,65 @@ We're also sharing updates on new features currently in development: --- -# New Experimental Features {/*new-experimental-features*/} +# Novos Recursos Experimentais {/*new-experimental-features*/} -`` has shipped in `react@19.2`. +`` foi lançado em `react@19.2`. -`` and `addTransitionType` are now available in `react@canary`. +`` e `addTransitionType` agora estão disponíveis em `react@canary`. -View Transitions and Activity are now ready for testing in `react@experimental`. These features have been tested in production and are stable, but the final API may still change as we incorporate feedback. +View Transitions e Activity estão agora prontos para teste em `react@experimental`. Esses recursos foram testados em produção e são estáveis, mas a API final ainda pode mudar à medida que incorporamos feedback. -You can try them by upgrading React packages to the most recent experimental version: +Você pode testá-los atualizando os pacotes do React para a versão experimental mais recente: - `react@experimental` - `react-dom@experimental` -Read on to learn how to use these features in your app, or check out the newly published docs: +Leia mais para saber como usar esses recursos em seu aplicativo, ou confira a documentação recém-publicada: -- [``](/reference/react/ViewTransition): A component that lets you activate an animation for a Transition. -- [`addTransitionType`](/reference/react/addTransitionType): A function that allows you to specify the cause of a Transition. -- [``](/reference/react/Activity): A component that lets you hide and show parts of the UI. +- [``](/reference/react/ViewTransition): Um componente que permite ativar uma animação para uma Transição. +- [`addTransitionType`](/reference/react/addTransitionType): Uma função que permite especificar a causa de uma Transição. +- [``](/reference/react/Activity): Um componente que permite ocultar e exibir partes da UI. -## View Transitions {/*view-transitions*/} +## Transições de Visualização {/*view-transitions*/} -React View Transitions are a new experimental feature that makes it easier to add animations to UI transitions in your app. Under-the-hood, these animations use the new [`startViewTransition`](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) API available in most modern browsers. +As Transições de Visualização do React são um novo recurso experimental que facilita a adição de animações às transições de UI em seu aplicativo. Internamente, essas animações usam a nova API [`startViewTransition`](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) disponível na maioria dos navegadores modernos. -To opt-in to animating an element, wrap it in the new `` component: +Para habilitar a animação de um elemento, envolva-o no novo componente ``: ```js -// "what" to animate. +// "o quê" animar. -
animate me
+
anime-me
``` -This new component lets you declaratively define "what" to animate when an animation is activated. +Este novo componente permite que você defina declarativamente "o quê" animar quando uma animação é ativada. -You can define "when" to animate by using one of these three triggers for a View Transition: +Você pode definir "quando" animar usando um destes três gatilhos para uma Transição de Visualização: ```js -// "when" to animate. +// "quando" animar. -// Transitions +// Transições startTransition(() => setState(...)); -// Deferred Values +// Valores Adiados const deferred = useDeferredValue(value); // Suspense }> -
Loading...
+
Carregando...
``` -By default, these animations use the [default CSS animations for View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations) applied (typically a smooth cross-fade). You can use [view transition pseudo-selectors](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#the_view_transition_pseudo-element_tree) to define "how" the animation runs. For example, you can use `*` to change the default animation for all transitions: +Por padrão, essas animações usam as [animações CSS padrão para Transições de Visualização](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations) aplicadas (tipicamente um cross-fade suave). Você pode usar [seletores pseudo de transição de visualização](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#the_view_transition_pseudo-element_tree) para definir "como" a animação é executada. Por exemplo, você pode usar `*` para alterar a animação padrão para todas as transições: ``` -// "how" to animate. +// "como" animar. ::view-transition-old(*) { animation: 300ms ease-out fade-out; } @@ -97,16 +97,16 @@ By default, these animations use the [default CSS animations for View Transition } ``` -When the DOM updates due to an animation trigger—like `startTransition`, `useDeferredValue`, or a `Suspense` fallback switching to content—React will use [declarative heuristics](/reference/react/ViewTransition#viewtransition) to automatically determine which `` components to activate for the animation. The browser will then run the animation that's defined in CSS. +Quando o DOM é atualizado devido a um gatilho de animação — como `startTransition`, `useDeferredValue`, ou um fallback de `Suspense` mudando para conteúdo — o React usará [heurísticas declarativas](/reference/react/ViewTransition#viewtransition) para determinar automaticamente quais componentes `` ativar para a animação. O navegador então executará a animação definida em CSS. -If you're familiar with the browser's View Transition API and want to know how React supports it, check out [How does `` Work](/reference/react/ViewTransition#how-does-viewtransition-work) in the docs. +Se você está familiarizado com a API de Transição de Visualização do navegador e quer saber como o React a suporta, confira [Como funciona ``](/reference/react/ViewTransition#how-does-viewtransition-work) na documentação. -In this post, let's take a look at a few examples of how to use View Transitions. +Nesta postagem, vamos dar uma olhada em alguns exemplos de como usar Transições de Visualização. -We'll start with this app, which doesn't animate any of the following interactions: -- Click a video to view the details. -- Click "back" to go back to the feed. -- Type in the list to filter the videos. +Começaremos com este aplicativo, que não anima nenhuma das seguintes interações: +- Clicar em um vídeo para ver os detalhes. +- Clicar em "voltar" para retornar ao feed. +- Digitar na lista para filtrar os vídeos. @@ -116,7 +116,7 @@ import TalkDetails from './Details'; import Home from './Home'; import {useRoute export default function App() { const {url} = useRouter(); - // 🚩This version doesn't include any animations yet + // 🚩Esta versão ainda não inclui nenhuma animação return url === '/' ? : ; } ``` @@ -162,7 +162,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Voltar } > @@ -192,7 +192,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -201,7 +201,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -232,11 +232,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos
}> + {count} Vídeos
}>
{foundVideos.length === 0 && ( -
No results
+
Nenhum resultado
)}
{foundVideos.map((video) => ( @@ -394,8 +394,8 @@ export default function Page({ heading, children }) { import {useState} from 'react'; import {Heart} from './Icons'; -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. +// Um hack, já que não temos um backend de verdade. +// Diferente do estado local, isso sobrevive à filtragem de vídeos. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -404,7 +404,7 @@ export default function LikeButton({video}) { return (
- } - > -
- - - - }> - - -
- - ); -} - -``` - -```js src/Home.js hidden -import { Video } from "./Videos"; -import Layout from "./Layout"; -import { fetchVideos } from "./data"; -import { useId, useState, use } from "react"; -import { IconSearch } from "./Icons"; - -function SearchInput({ value, onChange }) { - const id = useId(); - return ( - e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
- - ); -} - -function filterVideos(videos, query) { - const keywords = query - .toLowerCase() - .split(" ") - .filter((s) => s !== ""); - if (keywords.length === 0) { - return videos; - } - return videos.filter((video) => { - const words = (video.title + " " + video.description) - .toLowerCase() - .split(" "); - return keywords.every((kw) => words.some((w) => w.includes(kw))); - }); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(""); - const foundVideos = filterVideos(videos, searchText); - return ( - {count} Videos
}> - -
- {foundVideos.length === 0 && ( -
No results
- )} -
- {foundVideos.map((video) => ( -
-
- - ); -} - -``` - -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); -} - -export function PauseIcon() { - return ( - - - - ); -} - -export function PlayIcon() { - return ( - - - - ); -} -export function Heart({liked, animate}) { - return ( - <> - - - - - - {liked ? ( - - ) : ( - - )} - - - ); -} - -export function IconSearch(props) { - return ( - - - - ); -} -``` - -```js src/Layout.js -import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; - -export default function Page({ heading, children }) { - const isPending = useIsNavPending(); - - return ( -
-
-
- {heading} - {isPending && } -
-
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} - -
-
{children}
-
-
-
- ); -} -``` - -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; - -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); - -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); - return ( - - ); -} -``` - -```js src/Videos.js hidden -import { useState } from "react"; -import LikeButton from "./LikeButton"; -import { useRouter } from "./router"; -import { PauseIcon, PlayIcon } from "./Icons"; -import { startTransition } from "react"; - -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); - - return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - - ); -} - -export function Thumbnail({ video, children }) { - return ( - - ); -} - -export function Video({ video }) { - const { navigate } = useRouter(); - - return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - - -
-
{video.title}
-
{video.description}
-
-
- -
- ); -} -``` - - -```js src/data.js hidden -const videos = [ - { - id: '1', - title: 'First video', - description: 'Video description', - image: 'blue', - }, - { - id: '2', - title: 'Second video', - description: 'Video description', - image: 'red', - }, - { - id: '3', - title: 'Third video', - description: 'Video description', - image: 'green', - }, - { - id: '4', - title: 'Fourth video', - description: 'Video description', - image: 'purple', - }, - { - id: '5', - title: 'Fifth video', - description: 'Video description', - image: 'yellow', - }, - { - id: '6', - title: 'Sixth video', - description: 'Video description', - image: 'gray', - }, -]; - -let videosCache = new Map(); -let videoCache = new Map(); -let videoDetailsCache = new Map(); -const VIDEO_DELAY = 1; -const VIDEO_DETAILS_DELAY = 1000; -export function fetchVideos() { - if (videosCache.has(0)) { - return videosCache.get(0); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos); - }, VIDEO_DELAY); - }); - videosCache.set(0, promise); - return promise; -} - -export function fetchVideo(id) { - if (videoCache.has(id)) { - return videoCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DELAY); - }); - videoCache.set(id, promise); - return promise; -} - -export function fetchVideoDetails(id) { - if (videoDetailsCache.has(id)) { - return videoDetailsCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DETAILS_DELAY); - }); - videoDetailsCache.set(id, promise); - return promise; -} -``` - -```js src/router.js -import {useState, createContext,use,useTransition,useLayoutEffect,useEffect} from "react"; - -export function Router({ children }) { - const [isPending, startTransition] = useTransition(); - - function navigate(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - - - - const [routerState, setRouterState] = useState({ - pendingNav: () => {}, - url: document.location.pathname, - }); - - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - - - function navigateBack(url) { - startTransition(() => { - go(url); - }); - } - - useEffect(() => { - function handlePopState() { - // This should not animate because restoration has to be synchronous. - // Even though it's a transition. - startTransition(() => { - setRouterState({ - url: document.location.pathname + document.location.search, - pendingNav() { - // Noop. URL has already updated. - }, - }); - }); - } - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - const pendingNav = routerState.pendingNav; - useLayoutEffect(() => { - pendingNav(); - }, [pendingNav]); - - return ( - - {children} - - ); -} - -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); -} - -export function useIsNavPending() { - return use(RouterContext).isPending; -} -``` - -```css src/styles.css hidden -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -* { - box-sizing: border-box; -} - -html { - background-image: url(https://react.dev/images/meta-gradient-dark.png); - background-size: 100%; - background-position: -100%; - background-color: rgb(64 71 86); - background-repeat: no-repeat; - height: 100%; - width: 100%; -} - -body { - font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; - padding: 10px 0 10px 0; - margin: 0; - display: flex; - justify-content: center; -} - -#root { - flex: 1 1; - height: auto; - background-color: #fff; - border-radius: 10px; - max-width: 450px; - min-height: 600px; - padding-bottom: 10px; -} - -h1 { - margin-top: 0; - font-size: 22px; -} - -h2 { - margin-top: 0; - font-size: 20px; -} - -h3 { - margin-top: 0; - font-size: 18px; -} - -h4 { - margin-top: 0; - font-size: 16px; -} - -h5 { - margin-top: 0; - font-size: 14px; -} - -h6 { - margin-top: 0; - font-size: 12px; -} - -code { - font-size: 1.2em; -} - -ul { - padding-inline-start: 20px; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.absolute { - position: absolute; -} - -.overflow-visible { - overflow: visible; -} - -.visible { - overflow: visible; -} - -.fit { - width: fit-content; -} - - -/* Layout */ -.page { - display: flex; - flex-direction: column; - height: 100%; -} - -.top-hero { - height: 200px; - display: flex; - justify-content: center; - align-items: center; - background-image: conic-gradient( - from 90deg at -10% 100%, - #2b303b 0deg, - #2b303b 90deg, - #16181d 1turn - ); -} - -.bottom { - flex: 1; - overflow: auto; -} - -.top-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0; - padding: 0 12px; - top: 0; - width: 100%; - height: 44px; - color: #23272f; - font-weight: 700; - font-size: 20px; - z-index: 100; - cursor: default; -} - -.content { - padding: 0 12px; - margin-top: 4px; -} - - -.loader { - color: #23272f; - font-size: 3px; - width: 1em; - margin-right: 18px; - height: 1em; - border-radius: 50%; - position: relative; - text-indent: -9999em; - animation: loading-spinner 1.3s infinite linear; - animation-delay: 200ms; - transform: translateZ(0); -} - -@keyframes loading-spinner { - 0%, - 100% { - box-shadow: 0 -3em 0 0.2em, - 2em -2em 0 0em, 3em 0 0 -1em, - 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 0; - } - 12.5% { - box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, - 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 25% { - box-shadow: 0 -3em 0 -0.5em, - 2em -2em 0 0, 3em 0 0 0.2em, - 2em 2em 0 0, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 37.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, - -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 50% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, - -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 62.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, - -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; - } - 75% { - box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; - } - 87.5% { - box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; - } -} - -/* LikeButton */ -.like-button { - outline-offset: 2px; - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - cursor: pointer; - border-radius: 9999px; - border: none; - outline: none 2px; - color: #5e687e; - background: none; -} - -.like-button:focus { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); -} - -.like-button:active { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); - transform: scaleX(0.95) scaleY(0.95); -} - -.like-button:hover { - background-color: #f6f7f9; -} - -.like-button.liked { - color: #a6423a; -} - -/* Icons */ -@keyframes circle { - 0% { - transform: scale(0); - stroke-width: 16px; - } - - 50% { - transform: scale(.5); - stroke-width: 16px; - } - - to { - transform: scale(1); - stroke-width: 0; - } -} - -.circle { - color: rgba(166, 66, 58, .5); - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4,0,.2,1); -} - -.circle.liked.animate { - animation: circle .3s forwards; -} - -.heart { - width: 1.5rem; - height: 1.5rem; -} - -.heart.liked { - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4, 0, .2, 1); -} - -.heart.liked.animate { - animation: scale .35s ease-in-out forwards; -} - -.control-icon { - color: hsla(0, 0%, 100%, .5); - filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); -} - -.chevron-left { - margin-top: 2px; - rotate: 90deg; -} - - -/* Video */ -.thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 8rem; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.thumbnail.blue { - background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); -} - -.thumbnail.red { - background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); -} - -.thumbnail.green { - background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); -} - -.thumbnail.purple { - background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); -} - -.thumbnail.yellow { - background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); -} - -.thumbnail.gray { - background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); -} - -.video { - display: flex; - flex-direction: row; - gap: 0.75rem; - align-items: center; -} - -.video .link { - display: flex; - flex-direction: row; - flex: 1 1 0; - gap: 0.125rem; - outline-offset: 4px; - cursor: pointer; -} - -.video .info { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 8px; - gap: 0.125rem; -} - -.video .info:hover { - text-decoration: underline; -} - -.video-title { - font-size: 15px; - line-height: 1.25; - font-weight: 700; - color: #23272f; -} - -.video-description { - color: #5e687e; - font-size: 13px; -} - -/* Details */ -.details .thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 100%; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.video-details-title { - margin-top: 8px; -} - -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px -} - -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; -} - -.back:hover { - text-decoration: underline; -} - -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; -} - -.info-description { - margin: 8px 0 0 0; -} - -.controls { - cursor: pointer; -} - -.fallback { - background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; - background-size: 800px 104px; - display: block; - line-height: 1.25; - margin: 8px 0 0 0; - border-radius: 5px; - overflow: hidden; - - animation: 1s linear 1s infinite shimmer; - animation-delay: 300ms; - animation-duration: 1s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: shimmer; - animation-timing-function: linear; -} - - -.fallback.title { - width: 130px; - height: 30px; - -} - -.fallback.description { - width: 150px; - height: 21px; -} - -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } -} - -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} - -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; -} - -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; -} - -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} - -/* Home */ -.video-list { - position: relative; -} - -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` - -```json package.json hidden -{ - "dependencies": { - "react": "canary", - "react-dom": "canary", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - -
- -Since our router already updates the route using `startTransition`, this one line change to add `` activates with the default cross-fade animation. - -If you're curious how this works, see the docs for [How does `` work?](/reference/react/ViewTransition#how-does-viewtransition-work) - - - -#### Opting out of `` animations {/*opting-out-of-viewtransition-animations*/} - -In this example, we're wrapping the root of the app in `` for simplicity, but this means that all transitions in the app will be animated, which can lead to unexpected animations. - -To fix, we're wrapping route children with `"none"` so each page can control its own animation: - -```js -// Layout.js - - {children} - -``` - -In practice, navigations should be done via "enter" and "exit" props, or by using Transition Types. - - - -### Customizing animations {/*customizing-animations*/} - -By default, `` includes the default cross-fade from the browser. - -To customize animations, you can provide props to the `` component to specify which animations to use, based on [how the `` activates](/reference/react/ViewTransition#props). - -For example, we can slow down the `default` cross fade animation: - -```js - - - -``` - -And define `slow-fade` in CSS using [view transition classes](/reference/react/ViewTransition#view-transition-class): - -```css -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` - -Now, the cross fade is slower: - - - -```js src/App.js active -import { ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; - -export default function App() { - const { url } = useRouter(); - - // Define a default animation of .slow-fade. - // See animations.css for the animation definition. - return ( - - {url === '/' ? :
} - - ); -} -``` - -```js src/Details.js hidden -import { fetchVideo, fetchVideoDetails } from "./data"; -import { Thumbnail, VideoControls } from "./Videos"; -import { useRouter } from "./router"; -import Layout from "./Layout"; -import { use, Suspense } from "react"; -import { ChevronLeft } from "./Icons"; - -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} - -function VideoInfoFallback() { - return ( - <> -
-
- - ); -} - -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); - - return ( - { - navigateBack("/"); - }} - > - Back - - } - > -
- - - - }> - - -
-
- ); -} - -``` - -```js src/Home.js hidden -import { Video } from "./Videos"; -import Layout from "./Layout"; -import { fetchVideos } from "./data"; -import { useId, useState, use } from "react"; -import { IconSearch } from "./Icons"; - -function SearchInput({ value, onChange }) { - const id = useId(); - return ( -
e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
-
- ); -} - -function filterVideos(videos, query) { - const keywords = query - .toLowerCase() - .split(" ") - .filter((s) => s !== ""); - if (keywords.length === 0) { - return videos; - } - return videos.filter((video) => { - const words = (video.title + " " + video.description) - .toLowerCase() - .split(" "); - return keywords.every((kw) => words.some((w) => w.includes(kw))); - }); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(""); - const foundVideos = filterVideos(videos, searchText); - return ( - {count} Videos}> - -
- {foundVideos.length === 0 && ( -
No results
- )} -
- {foundVideos.map((video) => ( -
-
-
- ); -} - -``` - -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); -} - -export function PauseIcon() { - return ( - - - - ); -} - -export function PlayIcon() { - return ( - - - - ); -} -export function Heart({liked, animate}) { - return ( - <> - - - - - - {liked ? ( - - ) : ( - - )} - - - ); -} - -export function IconSearch(props) { - return ( - - - - ); -} -``` - -```js src/Layout.js hidden -import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; - -export default function Page({ heading, children }) { - const isPending = useIsNavPending(); - - return ( -
-
-
- {heading} - {isPending && } -
-
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} - -
-
{children}
-
-
-
- ); -} -``` - -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; - -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); - -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); - return ( - - ); -} -``` - -```js src/Videos.js hidden -import { useState } from "react"; -import LikeButton from "./LikeButton"; -import { useRouter } from "./router"; -import { PauseIcon, PlayIcon } from "./Icons"; -import { startTransition } from "react"; - -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); - - return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - - ); -} - -export function Thumbnail({ video, children }) { - return ( - - ); -} - -export function Video({ video }) { - const { navigate } = useRouter(); - - return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - - -
-
{video.title}
-
{video.description}
-
-
- -
- ); -} -``` - - -```js src/data.js hidden -const videos = [ - { - id: '1', - title: 'First video', - description: 'Video description', - image: 'blue', - }, - { - id: '2', - title: 'Second video', - description: 'Video description', - image: 'red', - }, - { - id: '3', - title: 'Third video', - description: 'Video description', - image: 'green', - }, - { - id: '4', - title: 'Fourth video', - description: 'Video description', - image: 'purple', - }, - { - id: '5', - title: 'Fifth video', - description: 'Video description', - image: 'yellow', - }, - { - id: '6', - title: 'Sixth video', - description: 'Video description', - image: 'gray', - }, -]; - -let videosCache = new Map(); -let videoCache = new Map(); -let videoDetailsCache = new Map(); -const VIDEO_DELAY = 1; -const VIDEO_DETAILS_DELAY = 1000; -export function fetchVideos() { - if (videosCache.has(0)) { - return videosCache.get(0); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos); - }, VIDEO_DELAY); - }); - videosCache.set(0, promise); - return promise; -} - -export function fetchVideo(id) { - if (videoCache.has(id)) { - return videoCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DELAY); - }); - videoCache.set(id, promise); - return promise; -} - -export function fetchVideoDetails(id) { - if (videoDetailsCache.has(id)) { - return videoDetailsCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DETAILS_DELAY); - }); - videoDetailsCache.set(id, promise); - return promise; -} -``` - -```js src/router.js hidden -import { - useState, - createContext, - use, - useTransition, - useLayoutEffect, - useEffect, -} from "react"; - -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); -} - -export function useIsNavPending() { - return use(RouterContext).isPending; -} - -export function Router({ children }) { - const [routerState, setRouterState] = useState({ - pendingNav: () => {}, - url: document.location.pathname, - }); - const [isPending, startTransition] = useTransition(); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - function navigate(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - function navigateBack(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - useEffect(() => { - function handlePopState() { - // This should not animate because restoration has to be synchronous. - // Even though it's a transition. - startTransition(() => { - setRouterState({ - url: document.location.pathname + document.location.search, - pendingNav() { - // Noop. URL has already updated. - }, - }); - }); - } - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - const pendingNav = routerState.pendingNav; - useLayoutEffect(() => { - pendingNav(); - }, [pendingNav]); - - return ( - - {children} - - ); -} -``` - -```css src/styles.css hidden -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -* { - box-sizing: border-box; -} - -html { - background-image: url(https://react.dev/images/meta-gradient-dark.png); - background-size: 100%; - background-position: -100%; - background-color: rgb(64 71 86); - background-repeat: no-repeat; - height: 100%; - width: 100%; -} - -body { - font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; - padding: 10px 0 10px 0; - margin: 0; - display: flex; - justify-content: center; -} - -#root { - flex: 1 1; - height: auto; - background-color: #fff; - border-radius: 10px; - max-width: 450px; - min-height: 600px; - padding-bottom: 10px; -} - -h1 { - margin-top: 0; - font-size: 22px; -} - -h2 { - margin-top: 0; - font-size: 20px; -} - -h3 { - margin-top: 0; - font-size: 18px; -} - -h4 { - margin-top: 0; - font-size: 16px; -} - -h5 { - margin-top: 0; - font-size: 14px; -} - -h6 { - margin-top: 0; - font-size: 12px; -} - -code { - font-size: 1.2em; -} - -ul { - padding-inline-start: 20px; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.absolute { - position: absolute; -} - -.overflow-visible { - overflow: visible; -} - -.visible { - overflow: visible; -} - -.fit { - width: fit-content; -} - - -/* Layout */ -.page { - display: flex; - flex-direction: column; - height: 100%; -} - -.top-hero { - height: 200px; - display: flex; - justify-content: center; - align-items: center; - background-image: conic-gradient( - from 90deg at -10% 100%, - #2b303b 0deg, - #2b303b 90deg, - #16181d 1turn - ); -} - -.bottom { - flex: 1; - overflow: auto; -} - -.top-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0; - padding: 0 12px; - top: 0; - width: 100%; - height: 44px; - color: #23272f; - font-weight: 700; - font-size: 20px; - z-index: 100; - cursor: default; -} - -.content { - padding: 0 12px; - margin-top: 4px; -} - - -.loader { - color: #23272f; - font-size: 3px; - width: 1em; - margin-right: 18px; - height: 1em; - border-radius: 50%; - position: relative; - text-indent: -9999em; - animation: loading-spinner 1.3s infinite linear; - animation-delay: 200ms; - transform: translateZ(0); -} - -@keyframes loading-spinner { - 0%, - 100% { - box-shadow: 0 -3em 0 0.2em, - 2em -2em 0 0em, 3em 0 0 -1em, - 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 0; - } - 12.5% { - box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, - 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 25% { - box-shadow: 0 -3em 0 -0.5em, - 2em -2em 0 0, 3em 0 0 0.2em, - 2em 2em 0 0, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 37.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, - -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 50% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, - -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 62.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, - -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; - } - 75% { - box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; - } - 87.5% { - box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; - } -} - -/* LikeButton */ -.like-button { - outline-offset: 2px; - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - cursor: pointer; - border-radius: 9999px; - border: none; - outline: none 2px; - color: #5e687e; - background: none; -} - -.like-button:focus { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); -} - -.like-button:active { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); - transform: scaleX(0.95) scaleY(0.95); -} - -.like-button:hover { - background-color: #f6f7f9; -} - -.like-button.liked { - color: #a6423a; -} - -/* Icons */ -@keyframes circle { - 0% { - transform: scale(0); - stroke-width: 16px; - } - - 50% { - transform: scale(.5); - stroke-width: 16px; - } - - to { - transform: scale(1); - stroke-width: 0; - } -} - -.circle { - color: rgba(166, 66, 58, .5); - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4,0,.2,1); -} - -.circle.liked.animate { - animation: circle .3s forwards; -} - -.heart { - width: 1.5rem; - height: 1.5rem; -} - -.heart.liked { - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4, 0, .2, 1); -} - -.heart.liked.animate { - animation: scale .35s ease-in-out forwards; -} - -.control-icon { - color: hsla(0, 0%, 100%, .5); - filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); -} - -.chevron-left { - margin-top: 2px; - rotate: 90deg; -} - - -/* Video */ -.thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 8rem; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.thumbnail.blue { - background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); -} - -.thumbnail.red { - background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); -} - -.thumbnail.green { - background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); -} - -.thumbnail.purple { - background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); -} - -.thumbnail.yellow { - background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); -} - -.thumbnail.gray { - background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); -} - -.video { - display: flex; - flex-direction: row; - gap: 0.75rem; - align-items: center; -} - -.video .link { - display: flex; - flex-direction: row; - flex: 1 1 0; - gap: 0.125rem; - outline-offset: 4px; - cursor: pointer; -} - -.video .info { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 8px; - gap: 0.125rem; -} - -.video .info:hover { - text-decoration: underline; -} - -.video-title { - font-size: 15px; - line-height: 1.25; - font-weight: 700; - color: #23272f; -} - -.video-description { - color: #5e687e; - font-size: 13px; -} - -/* Details */ -.details .thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 100%; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.video-details-title { - margin-top: 8px; -} - -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px -} - -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; -} - -.back:hover { - text-decoration: underline; -} - -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; -} - -.info-description { - margin: 8px 0 0 0; -} - -.controls { - cursor: pointer; -} - -.fallback { - background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; - background-size: 800px 104px; - display: block; - line-height: 1.25; - margin: 8px 0 0 0; - border-radius: 5px; - overflow: hidden; - - animation: 1s linear 1s infinite shimmer; - animation-delay: 300ms; - animation-duration: 1s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: shimmer; - animation-timing-function: linear; -} - - -.fallback.title { - width: 130px; - height: 30px; - -} - -.fallback.description { - width: 150px; - height: 21px; -} - -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } -} - -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} - -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; -} - -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; -} - -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} - -/* Home */ -.video-list { - position: relative; -} - -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` - - -```css src/animations.css -/* Define .slow-fade using view transition classes */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` - -```json package.json hidden -{ - "dependencies": { - "react": "canary", - "react-dom": "canary", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - - - -See [Styling View Transitions](/reference/react/ViewTransition#styling-view-transitions) for a full guide on styling ``. - -### Shared Element Transitions {/*shared-element-transitions*/} - -When two pages include the same element, often you want to animate it from one page to the next. - -To do this you can add a unique `name` to the ``: - -```js - - - -``` - -Now the video thumbnail animates between the two pages: - - - -```js src/App.js -import { ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; - -export default function App() { - const { url } = useRouter(); - - // Keeping our default slow-fade. - // This allows the content not in the shared - // element transition to cross-fade. - return ( - - {url === "/" ? :
} - - ); -} -``` - -```js src/Details.js hidden -import { fetchVideo, fetchVideoDetails } from "./data"; -import { Thumbnail, VideoControls } from "./Videos"; -import { useRouter } from "./router"; -import Layout from "./Layout"; -import { use, Suspense } from "react"; -import { ChevronLeft } from "./Icons"; - -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} - -function VideoInfoFallback() { - return ( - <> -
-
- - ); -} - -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); - - return ( - { - navigateBack("/"); - }} - > - Back - - } - > -
- - - - }> - - -
-
- ); -} - -``` - -```js src/Home.js hidden -import { Video } from "./Videos"; -import Layout from "./Layout"; -import { fetchVideos } from "./data"; -import { useId, useState, use } from "react"; -import { IconSearch } from "./Icons"; - -function SearchInput({ value, onChange }) { - const id = useId(); - return ( -
e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
-
- ); -} - -function filterVideos(videos, query) { - const keywords = query - .toLowerCase() - .split(" ") - .filter((s) => s !== ""); - if (keywords.length === 0) { - return videos; - } - return videos.filter((video) => { - const words = (video.title + " " + video.description) - .toLowerCase() - .split(" "); - return keywords.every((kw) => words.some((w) => w.includes(kw))); - }); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(""); - const foundVideos = filterVideos(videos, searchText); - return ( - {count} Videos}> - -
- {foundVideos.length === 0 && ( -
No results
- )} -
- {foundVideos.map((video) => ( -
-
-
- ); -} - -``` - -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); -} - -export function PauseIcon() { - return ( - - - - ); -} - -export function PlayIcon() { - return ( - - - - ); -} -export function Heart({liked, animate}) { - return ( - <> - - - - - - {liked ? ( - - ) : ( - - )} - - - ); -} - -export function IconSearch(props) { - return ( - - - - ); -} -``` - -```js src/Layout.js hidden -import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; - -export default function Page({ heading, children }) { - const isPending = useIsNavPending(); - - return ( -
-
-
- {heading} - {isPending && } -
-
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} - -
-
{children}
-
-
-
- ); -} -``` - -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; - -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); - -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); - return ( - - ); -} -``` - -```js src/Videos.js active -import { useState, ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react"; - -export function Thumbnail({ video, children }) { - // Add a name to animate with a shared element transition. - // This uses the default animation, no additional css needed. - return ( - - - - ); -} - -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); - - return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - - ); -} - -export function Video({ video }) { - const { navigate } = useRouter(); - - return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - - -
-
{video.title}
-
{video.description}
-
-
- -
- ); -} -``` - - -```js src/data.js hidden -const videos = [ - { - id: '1', - title: 'First video', - description: 'Video description', - image: 'blue', - }, - { - id: '2', - title: 'Second video', - description: 'Video description', - image: 'red', - }, - { - id: '3', - title: 'Third video', - description: 'Video description', - image: 'green', - }, - { - id: '4', - title: 'Fourth video', - description: 'Video description', - image: 'purple', - }, - { - id: '5', - title: 'Fifth video', - description: 'Video description', - image: 'yellow', - }, - { - id: '6', - title: 'Sixth video', - description: 'Video description', - image: 'gray', - }, -]; - -let videosCache = new Map(); -let videoCache = new Map(); -let videoDetailsCache = new Map(); -const VIDEO_DELAY = 1; -const VIDEO_DETAILS_DELAY = 1000; -export function fetchVideos() { - if (videosCache.has(0)) { - return videosCache.get(0); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos); - }, VIDEO_DELAY); - }); - videosCache.set(0, promise); - return promise; -} - -export function fetchVideo(id) { - if (videoCache.has(id)) { - return videoCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DELAY); - }); - videoCache.set(id, promise); - return promise; -} - -export function fetchVideoDetails(id) { - if (videoDetailsCache.has(id)) { - return videoDetailsCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DETAILS_DELAY); - }); - videoDetailsCache.set(id, promise); - return promise; -} -``` - -```js src/router.js hidden -import { - useState, - createContext, - use, - useTransition, - useLayoutEffect, - useEffect, -} from "react"; - -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); -} - -export function useIsNavPending() { - return use(RouterContext).isPending; -} - -export function Router({ children }) { - const [routerState, setRouterState] = useState({ - pendingNav: () => {}, - url: document.location.pathname, - }); - const [isPending, startTransition] = useTransition(); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - function navigate(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - function navigateBack(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - useEffect(() => { - function handlePopState() { - // This should not animate because restoration has to be synchronous. - // Even though it's a transition. - startTransition(() => { - setRouterState({ - url: document.location.pathname + document.location.search, - pendingNav() { - // Noop. URL has already updated. - }, - }); - }); - } - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - const pendingNav = routerState.pendingNav; - useLayoutEffect(() => { - pendingNav(); - }, [pendingNav]); - - return ( - - {children} - - ); -} -``` - -```css src/styles.css hidden -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -* { - box-sizing: border-box; -} - -html { - background-image: url(https://react.dev/images/meta-gradient-dark.png); - background-size: 100%; - background-position: -100%; - background-color: rgb(64 71 86); - background-repeat: no-repeat; - height: 100%; - width: 100%; -} - -body { - font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; - padding: 10px 0 10px 0; - margin: 0; - display: flex; - justify-content: center; -} - -#root { - flex: 1 1; - height: auto; - background-color: #fff; - border-radius: 10px; - max-width: 450px; - min-height: 600px; - padding-bottom: 10px; -} - -h1 { - margin-top: 0; - font-size: 22px; -} - -h2 { - margin-top: 0; - font-size: 20px; -} - -h3 { - margin-top: 0; - font-size: 18px; -} - -h4 { - margin-top: 0; - font-size: 16px; -} - -h5 { - margin-top: 0; - font-size: 14px; -} - -h6 { - margin-top: 0; - font-size: 12px; -} - -code { - font-size: 1.2em; -} - -ul { - padding-inline-start: 20px; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.absolute { - position: absolute; -} - -.overflow-visible { - overflow: visible; -} - -.visible { - overflow: visible; -} - -.fit { - width: fit-content; -} - - -/* Layout */ -.page { - display: flex; - flex-direction: column; - height: 100%; -} - -.top-hero { - height: 200px; - display: flex; - justify-content: center; - align-items: center; - background-image: conic-gradient( - from 90deg at -10% 100%, - #2b303b 0deg, - #2b303b 90deg, - #16181d 1turn - ); -} - -.bottom { - flex: 1; - overflow: auto; -} - -.top-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0; - padding: 0 12px; - top: 0; - width: 100%; - height: 44px; - color: #23272f; - font-weight: 700; - font-size: 20px; - z-index: 100; - cursor: default; -} - -.content { - padding: 0 12px; - margin-top: 4px; -} - - -.loader { - color: #23272f; - font-size: 3px; - width: 1em; - margin-right: 18px; - height: 1em; - border-radius: 50%; - position: relative; - text-indent: -9999em; - animation: loading-spinner 1.3s infinite linear; - animation-delay: 200ms; - transform: translateZ(0); -} - -@keyframes loading-spinner { - 0%, - 100% { - box-shadow: 0 -3em 0 0.2em, - 2em -2em 0 0em, 3em 0 0 -1em, - 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 0; - } - 12.5% { - box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, - 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 25% { - box-shadow: 0 -3em 0 -0.5em, - 2em -2em 0 0, 3em 0 0 0.2em, - 2em 2em 0 0, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 37.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, - -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 50% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, - -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 62.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, - -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; - } - 75% { - box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; - } - 87.5% { - box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; - } -} - -/* LikeButton */ -.like-button { - outline-offset: 2px; - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - cursor: pointer; - border-radius: 9999px; - border: none; - outline: none 2px; - color: #5e687e; - background: none; -} - -.like-button:focus { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); -} - -.like-button:active { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); - transform: scaleX(0.95) scaleY(0.95); -} - -.like-button:hover { - background-color: #f6f7f9; -} - -.like-button.liked { - color: #a6423a; -} - -/* Icons */ -@keyframes circle { - 0% { - transform: scale(0); - stroke-width: 16px; - } - - 50% { - transform: scale(.5); - stroke-width: 16px; - } - - to { - transform: scale(1); - stroke-width: 0; - } -} - -.circle { - color: rgba(166, 66, 58, .5); - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4,0,.2,1); -} - -.circle.liked.animate { - animation: circle .3s forwards; -} - -.heart { - width: 1.5rem; - height: 1.5rem; -} - -.heart.liked { - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4, 0, .2, 1); -} - -.heart.liked.animate { - animation: scale .35s ease-in-out forwards; -} - -.control-icon { - color: hsla(0, 0%, 100%, .5); - filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); -} - -.chevron-left { - margin-top: 2px; - rotate: 90deg; -} - - -/* Video */ -.thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 8rem; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.thumbnail.blue { - background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); -} - -.thumbnail.red { - background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); -} - -.thumbnail.green { - background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); -} - -.thumbnail.purple { - background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); -} - -.thumbnail.yellow { - background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); -} - -.thumbnail.gray { - background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); -} - -.video { - display: flex; - flex-direction: row; - gap: 0.75rem; - align-items: center; -} - -.video .link { - display: flex; - flex-direction: row; - flex: 1 1 0; - gap: 0.125rem; - outline-offset: 4px; - cursor: pointer; -} - -.video .info { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 8px; - gap: 0.125rem; -} - -.video .info:hover { - text-decoration: underline; -} - -.video-title { - font-size: 15px; - line-height: 1.25; - font-weight: 700; - color: #23272f; -} - -.video-description { - color: #5e687e; - font-size: 13px; -} - -/* Details */ -.details .thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 100%; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.video-details-title { - margin-top: 8px; -} - -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px -} - -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; -} - -.back:hover { - text-decoration: underline; -} - -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; -} - -.info-description { - margin: 8px 0 0 0; -} - -.controls { - cursor: pointer; -} - -.fallback { - background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; - background-size: 800px 104px; - display: block; - line-height: 1.25; - margin: 8px 0 0 0; - border-radius: 5px; - overflow: hidden; - - animation: 1s linear 1s infinite shimmer; - animation-delay: 300ms; - animation-duration: 1s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: shimmer; - animation-timing-function: linear; -} - - -.fallback.title { - width: 130px; - height: 30px; - -} - -.fallback.description { - width: 150px; - height: 21px; -} - -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } -} - -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} - -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; -} - -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; -} - -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} - -/* Home */ -.video-list { - position: relative; -} - -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` - - -```css src/animations.css -/* No additional animations needed */ - - - - - - - - - -/* Previously defined animations below */ - - - - - -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` - -```json package.json hidden -{ - "dependencies": { - "react": "canary", - "react-dom": "canary", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - - - -By default, React automatically generates a unique `name` for each element activated for a transition (see [How does `` work](/reference/react/ViewTransition#how-does-viewtransition-work)). When React sees a transition where a `` with a `name` is removed and a new `` with the same `name` is added, it will activate a shared element transition. - -For more info, see the docs for [Animating a Shared Element](/reference/react/ViewTransition#animating-a-shared-element). - -### Animating based on cause {/*animating-based-on-cause*/} - -Sometimes, you may want elements to animate differently based on how it was triggered. For this use case, we've added a new API called `addTransitionType` to specify the cause of a transition: - -```js {4,11} -function navigate(url) { - startTransition(() => { - // Transition type for the cause "nav forward" - addTransitionType('nav-forward'); - go(url); - }); -} -function navigateBack(url) { - startTransition(() => { - // Transition type for the cause "nav backward" - addTransitionType('nav-back'); - go(url); - }); -} -``` - -With transition types, you can provide custom animations via props to ``. Let's add a shared element transition to the header for "6 Videos" and "Back": - -```js {4,5} - - {heading} - -``` - -Here we pass a `share` prop to define how to animate based on the transition type. When the share transition activates from `nav-forward`, the view transition class `slide-forward` is applied. When it's from `nav-back`, the `slide-back` animation is activated. Let's define these animations in CSS: - -```css -::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ - animation: ... -} - -::view-transition-new(.slide-forward) { - /* when sliding forward, the "new" page should slide in from right. */ - animation: ... -} - -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: ... -} - -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: ... -} -``` - -Now we can animate the header along with thumbnail based on navigation type: - - - -```js src/App.js hidden -import { ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; - -export default function App() { - const { url } = useRouter(); - - // Keeping our default slow-fade. - return ( - - {url === "/" ? :
} - - ); -} -``` - -```js src/Details.js hidden -import { fetchVideo, fetchVideoDetails } from "./data"; -import { Thumbnail, VideoControls } from "./Videos"; -import { useRouter } from "./router"; -import Layout from "./Layout"; -import { use, Suspense } from "react"; -import { ChevronLeft } from "./Icons"; - -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} - -function VideoInfoFallback() { - return ( - <> -
-
- - ); -} - -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); - - return ( - { - navigateBack("/"); - }} - > - Back - - } - > -
- - - - }> - - -
-
- ); -} - -``` - -```js src/Home.js hidden -import { Video } from "./Videos"; -import Layout from "./Layout"; -import { fetchVideos } from "./data"; -import { useId, useState, use } from "react"; -import { IconSearch } from "./Icons"; - -function SearchInput({ value, onChange }) { - const id = useId(); - return ( -
e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
-
- ); -} - -function filterVideos(videos, query) { - const keywords = query - .toLowerCase() - .split(" ") - .filter((s) => s !== ""); - if (keywords.length === 0) { - return videos; - } - return videos.filter((video) => { - const words = (video.title + " " + video.description) - .toLowerCase() - .split(" "); - return keywords.every((kw) => words.some((w) => w.includes(kw))); - }); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(""); - const foundVideos = filterVideos(videos, searchText); - return ( - {count} Videos}> - -
- {foundVideos.length === 0 && ( -
No results
- )} -
- {foundVideos.map((video) => ( -
-
-
- ); -} - -``` - -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); -} - -export function PauseIcon() { - return ( - - - - ); -} - -export function PlayIcon() { - return ( - - - - ); -} -export function Heart({liked, animate}) { - return ( - <> - - - - - - {liked ? ( - - ) : ( - - )} - - - ); -} - -export function IconSearch(props) { - return ( - - - - ); -} -``` - -```js src/Layout.js active -import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; - -export default function Page({ heading, children }) { - const isPending = useIsNavPending(); - return ( -
-
-
- {/* Custom classes based on transition type. */} - - {heading} - - {isPending && } -
-
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} - -
-
{children}
-
-
-
- ); -} -``` - -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; - -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); - -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); - return ( - - ); -} -``` - -```js src/Videos.js hidden -import { useState, ViewTransition } from "react"; -import LikeButton from "./LikeButton"; -import { useRouter } from "./router"; -import { PauseIcon, PlayIcon } from "./Icons"; -import { startTransition } from "react"; - -export function Thumbnail({ video, children }) { - // Add a name to animate with a shared element transition. - // This uses the default animation, no additional css needed. - return ( - - - - ); -} - -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); - - return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - - ); -} - -export function Video({ video }) { - const { navigate } = useRouter(); - - return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - - -
-
{video.title}
-
{video.description}
-
-
- -
- ); -} -``` - - -```js src/data.js hidden -const videos = [ - { - id: '1', - title: 'First video', - description: 'Video description', - image: 'blue', - }, - { - id: '2', - title: 'Second video', - description: 'Video description', - image: 'red', - }, - { - id: '3', - title: 'Third video', - description: 'Video description', - image: 'green', - }, - { - id: '4', - title: 'Fourth video', - description: 'Video description', - image: 'purple', - }, - { - id: '5', - title: 'Fifth video', - description: 'Video description', - image: 'yellow', - }, - { - id: '6', - title: 'Sixth video', - description: 'Video description', - image: 'gray', - }, -]; - -let videosCache = new Map(); -let videoCache = new Map(); -let videoDetailsCache = new Map(); -const VIDEO_DELAY = 1; -const VIDEO_DETAILS_DELAY = 1000; -export function fetchVideos() { - if (videosCache.has(0)) { - return videosCache.get(0); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos); - }, VIDEO_DELAY); - }); - videosCache.set(0, promise); - return promise; -} - -export function fetchVideo(id) { - if (videoCache.has(id)) { - return videoCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DELAY); - }); - videoCache.set(id, promise); - return promise; -} - -export function fetchVideoDetails(id) { - if (videoDetailsCache.has(id)) { - return videoDetailsCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DETAILS_DELAY); - }); - videoDetailsCache.set(id, promise); - return promise; -} -``` - -```js src/router.js -import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, addTransitionType} from "react"; - -export function Router({ children }) { - const [isPending, startTransition] = useTransition(); - - function navigate(url) { - startTransition(() => { - // Transition type for the cause "nav forward" - addTransitionType('nav-forward'); - go(url); - }); - } - function navigateBack(url) { - startTransition(() => { - // Transition type for the cause "nav backward" - addTransitionType('nav-back'); - go(url); - }); - } - - - const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - - useEffect(() => { - function handlePopState() { - // This should not animate because restoration has to be synchronous. - // Even though it's a transition. - startTransition(() => { - setRouterState({ - url: document.location.pathname + document.location.search, - pendingNav() { - // Noop. URL has already updated. - }, - }); - }); - } - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - const pendingNav = routerState.pendingNav; - useLayoutEffect(() => { - pendingNav(); - }, [pendingNav]); - - return ( - - {children} - - ); -} - -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); -} - -export function useIsNavPending() { - return use(RouterContext).isPending; -} - -``` - -```css src/styles.css hidden -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -* { - box-sizing: border-box; -} - -html { - background-image: url(https://react.dev/images/meta-gradient-dark.png); - background-size: 100%; - background-position: -100%; - background-color: rgb(64 71 86); - background-repeat: no-repeat; - height: 100%; - width: 100%; -} - -body { - font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; - padding: 10px 0 10px 0; - margin: 0; - display: flex; - justify-content: center; -} - -#root { - flex: 1 1; - height: auto; - background-color: #fff; - border-radius: 10px; - max-width: 450px; - min-height: 600px; - padding-bottom: 10px; -} - -h1 { - margin-top: 0; - font-size: 22px; -} - -h2 { - margin-top: 0; - font-size: 20px; -} - -h3 { - margin-top: 0; - font-size: 18px; -} - -h4 { - margin-top: 0; - font-size: 16px; -} - -h5 { - margin-top: 0; - font-size: 14px; -} - -h6 { - margin-top: 0; - font-size: 12px; -} - -code { - font-size: 1.2em; -} - -ul { - padding-inline-start: 20px; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.absolute { - position: absolute; -} - -.overflow-visible { - overflow: visible; -} - -.visible { - overflow: visible; -} - -.fit { - width: fit-content; -} - - -/* Layout */ -.page { - display: flex; - flex-direction: column; - height: 100%; -} - -.top-hero { - height: 200px; - display: flex; - justify-content: center; - align-items: center; - background-image: conic-gradient( - from 90deg at -10% 100%, - #2b303b 0deg, - #2b303b 90deg, - #16181d 1turn - ); -} - -.bottom { - flex: 1; - overflow: auto; -} - -.top-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0; - padding: 0 12px; - top: 0; - width: 100%; - height: 44px; - color: #23272f; - font-weight: 700; - font-size: 20px; - z-index: 100; - cursor: default; -} - -.content { - padding: 0 12px; - margin-top: 4px; -} - - -.loader { - color: #23272f; - font-size: 3px; - width: 1em; - margin-right: 18px; - height: 1em; - border-radius: 50%; - position: relative; - text-indent: -9999em; - animation: loading-spinner 1.3s infinite linear; - animation-delay: 200ms; - transform: translateZ(0); -} - -@keyframes loading-spinner { - 0%, - 100% { - box-shadow: 0 -3em 0 0.2em, - 2em -2em 0 0em, 3em 0 0 -1em, - 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 0; - } - 12.5% { - box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, - 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 25% { - box-shadow: 0 -3em 0 -0.5em, - 2em -2em 0 0, 3em 0 0 0.2em, - 2em 2em 0 0, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 37.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, - -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 50% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, - -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 62.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, - -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; - } - 75% { - box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; - } - 87.5% { - box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; - } -} - -/* LikeButton */ -.like-button { - outline-offset: 2px; - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - cursor: pointer; - border-radius: 9999px; - border: none; - outline: none 2px; - color: #5e687e; - background: none; -} - -.like-button:focus { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); -} - -.like-button:active { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); - transform: scaleX(0.95) scaleY(0.95); -} - -.like-button:hover { - background-color: #f6f7f9; -} - -.like-button.liked { - color: #a6423a; -} - -/* Icons */ -@keyframes circle { - 0% { - transform: scale(0); - stroke-width: 16px; - } - - 50% { - transform: scale(.5); - stroke-width: 16px; - } - - to { - transform: scale(1); - stroke-width: 0; - } -} - -.circle { - color: rgba(166, 66, 58, .5); - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4,0,.2,1); -} - -.circle.liked.animate { - animation: circle .3s forwards; -} - -.heart { - width: 1.5rem; - height: 1.5rem; -} - -.heart.liked { - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4, 0, .2, 1); -} - -.heart.liked.animate { - animation: scale .35s ease-in-out forwards; -} - -.control-icon { - color: hsla(0, 0%, 100%, .5); - filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); -} - -.chevron-left { - margin-top: 2px; - rotate: 90deg; -} - - -/* Video */ -.thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 8rem; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.thumbnail.blue { - background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); -} - -.thumbnail.red { - background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); -} - -.thumbnail.green { - background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); -} - -.thumbnail.purple { - background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); -} - -.thumbnail.yellow { - background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); -} - -.thumbnail.gray { - background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); -} - -.video { - display: flex; - flex-direction: row; - gap: 0.75rem; - align-items: center; -} - -.video .link { - display: flex; - flex-direction: row; - flex: 1 1 0; - gap: 0.125rem; - outline-offset: 4px; - cursor: pointer; -} - -.video .info { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 8px; - gap: 0.125rem; -} - -.video .info:hover { - text-decoration: underline; -} - -.video-title { - font-size: 15px; - line-height: 1.25; - font-weight: 700; - color: #23272f; -} - -.video-description { - color: #5e687e; - font-size: 13px; -} - -/* Details */ -.details .thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 100%; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.video-details-title { - margin-top: 8px; -} - -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px -} - -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; -} - -.back:hover { - text-decoration: underline; -} - -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; -} - -.info-description { - margin: 8px 0 0 0; -} - -.controls { - cursor: pointer; -} - -.fallback { - background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; - background-size: 800px 104px; - display: block; - line-height: 1.25; - margin: 8px 0 0 0; - border-radius: 5px; - overflow: hidden; - - animation: 1s linear 1s infinite shimmer; - animation-delay: 300ms; - animation-duration: 1s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: shimmer; - animation-timing-function: linear; -} - - -.fallback.title { - width: 130px; - height: 30px; - -} - -.fallback.description { - width: 150px; - height: 21px; -} - -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } -} - -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} - -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; -} - -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; -} - -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} - -/* Home */ -.video-list { - position: relative; -} - -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` - - -```css src/animations.css -/* Animations for view transition classed added by transition type */ -::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; -} - -::view-transition-new(.slide-forward) { - /* when sliding forward, the "new" page should slide in from right. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; -} - -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; -} - -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; -} - -/* New keyframes to support our animations above. */ -@keyframes fade-in { - from { - opacity: 0; - } -} - -@keyframes fade-out { - to { - opacity: 0; - } -} - -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} - -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } -} - -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } -} - -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } -} - -/* Previously defined animations. */ - -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` - -```json package.json hidden -{ - "dependencies": { - "react": "canary", - "react-dom": "canary", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - - - -### Animating Suspense Boundaries {/*animating-suspense-boundaries*/} - -Suspense will also activate View Transitions. - -To animate the fallback to content, we can wrap `Suspense` with ``: - -```js - - }> - - - -``` - -By adding this, the fallback will cross-fade into the content. Click a video and see the video info animate in: - - - -```js src/App.js hidden -import { ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; - -export default function App() { - const { url } = useRouter(); - - // Default slow-fade animation. - return ( - - {url === "/" ? :
} - - ); -} -``` - -```js src/Details.js active -import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; - -function VideoDetails({ id }) { - // Cross-fade the fallback to content. - return ( - - }> - - - - ); -} - -function VideoInfoFallback() { - return ( -
-
-
-
- ); -} - -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); return ( - + }> + + ); } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( -
-

{details.title}

-

{details.description}

-
- ); -} ``` ```js src/Home.js hidden @@ -6471,7 +1109,7 @@ export function Heart({liked, animate}) { )} @@ -6496,25 +1134,17 @@ export function IconSearch(props) { } ``` -```js src/Layout.js hidden -import {ViewTransition} from 'react'; -import { useIsNavPending } from "./router"; +```js src/Layout.js +import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; export default function Page({ heading, children }) { const isPending = useIsNavPending(); + return (
- {/* Custom classes based on transition type. */} - - {heading} - + {heading} {isPending && }
@@ -6559,30 +1189,14 @@ export default function LikeButton({video}) { ); } -``` - -```js src/Videos.js hidden -import { useState, ViewTransition } from "react"; -import LikeButton from "./LikeButton"; -import { useRouter } from "./router"; -import { PauseIcon, PlayIcon } from "./Icons"; -import { startTransition } from "react"; - -export function Thumbnail({ video, children }) { - // Add a name to animate with a shared element transition. - // This uses the default animation, no additional css needed. - return ( - - - - ); -} +``` + +```js src/Videos.js hidden +import { useState } from "react"; +import LikeButton from "./LikeButton"; +import { useRouter } from "./router"; +import { PauseIcon, PlayIcon } from "./Icons"; +import { startTransition } from "react"; export function VideoControls() { const [isPlaying, setIsPlaying] = useState(false); @@ -6601,6 +1215,18 @@ export function VideoControls() { ); } +export function Thumbnail({ video, children }) { + return ( + + ); +} + export function Video({ video }) { const { navigate } = useRouter(); @@ -6712,27 +1338,28 @@ export function fetchVideoDetails(id) { } ``` -```js src/router.js hidden -import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, addTransitionType} from "react"; +```js src/router.js +import {useState, createContext,use,useTransition,useLayoutEffect,useEffect} from "react"; export function Router({ children }) { const [isPending, startTransition] = useTransition(); - const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + function navigate(url) { + // Update router state in transition. startTransition(() => { - // Transition type for the cause "nav forward" - addTransitionType('nav-forward'); - go(url); - }); - } - function navigateBack(url) { - startTransition(() => { - // Transition type for the cause "nav backward" - addTransitionType('nav-back'); go(url); }); } + + + + const [routerState, setRouterState] = useState({ + pendingNav: () => {}, + url: document.location.pathname, + }); + + function go(url) { setRouterState({ url, @@ -6742,6 +1369,13 @@ export function Router({ children }) { }); } + + function navigateBack(url) { + startTransition(() => { + go(url); + }); + } + useEffect(() => { function handlePopState() { // This should not animate because restoration has to be synchronous. @@ -6789,9 +1423,7 @@ export function useRouter() { export function useIsNavPending() { return use(RouterContext).isPending; } - ``` - ```css src/styles.css hidden @font-face { font-family: Optimistic Text; @@ -7360,189 +1992,276 @@ ul { } ``` +```js src/index.js hidden +import React, {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import './styles.css'; -```css src/animations.css -/* Slide the fallback down */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; -} +import App from './App'; +import {Router} from './router'; -/* Slide the content up */ -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; -} +const root = createRoot(document.getElementById('root')); +root.render( + + + + + +); +``` -/* Define the new keyframes */ -@keyframes slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } +```json package.json hidden +{ + "dependencies": { + "react": "canary", + "react-dom": "canary", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } } +``` -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } -} + -/* Previously defined animations below */ +Como nosso roteador já atualiza a rota usando `startTransition`, esta única linha de código para adicionar `` ativa a animação de cross-fade padrão. -/* Animations for view transition classed added by transition type */ -::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; -} +Se você estiver curioso sobre como isso funciona, consulte a documentação de [Como funciona ``?](/reference/react/ViewTransition#how-does-viewtransition-work) -::view-transition-new(.slide-forward) { - /* when sliding forward, the "new" page should slide in from right. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; -} + -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; -} +#### Optando por não usar animações de `` {/*opting-out-of-viewtransition-animations*/} -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; -} +Neste exemplo, estamos envolvendo a raiz do aplicativo com `` por simplicidade, mas isso significa que todas as transições no aplicativo serão animadas, o que pode levar a animações inesperadas. -/* Keyframes to support our animations above. */ -@keyframes fade-in { - from { - opacity: 0; - } -} +Para corrigir, estamos envolvendo os filhos das rotas com `"none"`, para que cada página possa controlar sua própria animação: -@keyframes fade-out { - to { - opacity: 0; - } +```js +// Layout.js + + {children} + +``` + +Na prática, as navegações devem ser feitas através das props "enter" e "exit", ou usando Tipos de Transição. + + + +### Personalizando animações {/*customizing-animations*/} + +Por padrão, `` inclui o fade cruzado padrão do navegador. + +Para personalizar animações, você pode fornecer props ao componente `` para especificar quais animações usar, com base em [como o `` é ativado](/reference/react/ViewTransition#props). + +Por exemplo, podemos desacelerar a animação de fade cruzado `default`: + +```js + + + +``` + +E definir `slow-fade` em CSS usando [classes de transição de visualização](/reference/react/ViewTransition#view-transition-class): + +```css +::view-transition-old(.slow-fade) { + animation-duration: 500ms; } -@keyframes slide-to-right { - to { - transform: translateX(50px); - } +::view-transition-new(.slow-fade) { + animation-duration: 500ms; } +``` -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } +Agora, o fade cruzado é mais lento: + + + +```js src/App.js active +import { ViewTransition } from "react"; +import Details from "./Details"; +import Home from "./Home"; +import { useRouter } from "./router"; + +export default function App() { + const { url } = useRouter(); + + // Define uma animação padrão de .slow-fade. + // Veja animations.css para a definição da animação. + return ( + + {url === '/' ? :
} + + ); } +``` -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } +```js src/Details.js hidden +import { fetchVideo, fetchVideoDetails } from "./data"; +import { Thumbnail, VideoControls } from "./Videos"; +import { useRouter } from "./router"; +import Layout from "./Layout"; +import { use, Suspense } from "react"; +import { ChevronLeft } from "./Icons"; + +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); + return ( + <> +

{details.title}

+

{details.description}

+ + ); } -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } +function VideoInfoFallback() { + return ( + <> +
+
+ + ); } -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); + + return ( + { + navigateBack("/"); + }} + > + Back +
+ } + > +
+ + + + }> + + +
+ + ); } -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} ``` -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; +```js src/Home.js hidden +import { Video } from "./Videos"; +import Layout from "./Layout"; +import { fetchVideos } from "./data"; +import { useId, useState, use } from "react"; +import { IconSearch } from "./Icons"; -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); +} -```json package.json hidden -{ - "dependencies": { - "react": "canary", - "react-dom": "canary", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" +function filterVideos(videos, query) { + const keywords = query + .toLowerCase() + .split(" ") + .filter((s) => s !== ""); + if (keywords.length === 0) { + return videos; } + return videos.filter((video) => { + const words = (video.title + " " + video.description) + .toLowerCase() + .split(" "); + return keywords.every((kw) => words.some((w) => w.includes(kw))); + }); } -``` - - -We can also provide custom animations using an `exit` on the fallback, and `enter` on the content: +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(""); + const foundVideos = filterVideos(videos, searchText); + return ( + {count} Videos}> + +
+ {foundVideos.length === 0 && ( +
No results
+ )} +
+ {foundVideos.map((video) => ( +
+
+
+ ); +} -```js {3,8} - - - - } -> - - - - ``` -Here's how we'll define `slide-down` and `slide-up` with CSS: +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + {url === "/" ? :
} @@ -7560,24 +2281,21 @@ export default function App() { } ``` -```js src/Details.js active -import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; +```js src/Details.js hidden +import { fetchVideo, fetchVideoDetails } from "./data"; +import { Thumbnail, VideoControls } from "./Videos"; +import { useRouter } from "./router"; +import Layout from "./Layout"; +import { use, Suspense } from "react"; +import { ChevronLeft } from "./Icons"; -function VideoDetails({ id }) { +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); return ( - - - - } - > - {/* Animate the content up */} - - - - + <> +

{details.title}

+

{details.description}

+ ); } @@ -7604,7 +2322,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Voltar } > @@ -7612,21 +2330,14 @@ export default function Details() { - + }> + + ); } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} ``` ```js src/Home.js hidden @@ -7641,7 +2352,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -7650,7 +2361,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -7681,11 +2392,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos
}> + {count} Vídeos
}>
{foundVideos.length === 0 && ( -
No results
+
Nenhum resultado
)}
{foundVideos.map((video) => ( @@ -7792,7 +2503,7 @@ export function Heart({liked, animate}) { )} @@ -7818,29 +2529,21 @@ export function IconSearch(props) { ``` ```js src/Layout.js hidden -import {ViewTransition} from 'react'; -import { useIsNavPending } from "./router"; +import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; export default function Page({ heading, children }) { const isPending = useIsNavPending(); + return (
- {/* Custom classes based on transition type. */} - - {heading} - + {heading} {isPending && }
{/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Content can define its own ViewTransition. */}
{children}
@@ -7865,7 +2568,7 @@ export default function LikeButton({video}) { return (
}> + +
+ {foundVideos.length === 0 && ( +
Nenhum resultado
+ )} +
+ {foundVideos.map((video) => ( +
+
+ + ); } +``` -.fallback.title { - width: 130px; - height: 30px; - -} - -.fallback.description { - width: 150px; - height: 21px; +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); } -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } +export function PauseIcon() { + return ( + + + + ); } -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; +export function PlayIcon() { + return ( + + + + ); } +export function Heart({liked, animate}) { + return ( + <> + + + -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; + + {liked ? ( + + ) : ( + + )} + + + ); } -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; +export function IconSearch(props) { + return ( + + + + ); } +``` -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} +```js src/Layout.js hidden +import {ViewTransition} from 'react'; +import { useIsNavPending } from "./router"; -/* Home */ -.video-list { - position: relative; +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Classes personalizadas baseadas no tipo de transição. */} + + {heading} + + {isPending && } +
+
+ {/* Opt-out da ViewTransition para o conteúdo. */} + {/* O conteúdo pode definir sua própria ViewTransition. */} + +
+
{children}
+
+
+
+ ); } +``` -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; + +// Um hack, já que não temos um backend de verdade. +// Diferente do estado local, isso sobrevive a vídeos sendo filtrados. +const likedVideos = new Set(); + +export default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + return ( + + ); } ``` +```js src/Videos.js hidden +import { useState, ViewTransition } from "react"; +import LikeButton from "./LikeButton"; +import { useRouter } from "./router"; +import { PauseIcon, PlayIcon } from "./Icons"; +import { startTransition } from "react"; -```css src/animations.css -/* Slide the fallback down */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; +export function Thumbnail({ video, children }) { + // Adiciona um nome para animar com uma transição de elemento compartilhado. + // Isso usa a animação padrão, sem necessidade de CSS adicional. + return ( + + + + ); } -/* Slide the content up */ -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; -} +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); -/* Define the new keyframes */ -@keyframes slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); } -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } -} +export function Video({ video }) { + const { navigate } = useRouter(); -/* Previously defined animations below */ + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + -/* Animations for view transition classed added by transition type */ -::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; +
+
{video.title}
+
{video.description}
+
+
+ +
+ ); } +``` -::view-transition-new(.slide-forward) { - /* when sliding forward, the "new" page should slide in from right. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; -} -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; -} +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'Primeiro vídeo', + description: 'Descrição do vídeo', + image: 'blue', + }, + { + id: '2', + title: 'Segundo vídeo', + description: 'Descrição do vídeo', + image: 'red', + }, + { + id: '3', + title: 'Terceiro vídeo', + description: 'Descrição do vídeo', + image: 'green', + }, + { + id: '4', + title: 'Quarto vídeo', + description: 'Descrição do vídeo', + image: 'purple', + }, + { + id: '5', + title: 'Quinto vídeo', + description: 'Descrição do vídeo', + image: 'yellow', + }, + { + id: '6', + title: 'Sexto vídeo', + description: 'Descrição do vídeo', + image: 'gray', + }, +]; -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; +let videosCache = new Map(); +let videoCache = new Map(); +let videoDetailsCache = new Map(); +const VIDEO_DELAY = 1; +const VIDEO_DETAILS_DELAY = 1000; +export function fetchVideos() { + if (videosCache.has(0)) { + return videosCache.get(0); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos); + }, VIDEO_DELAY); + }); + videosCache.set(0, promise); + return promise; } -/* Keyframes to support our animations above. */ -@keyframes fade-in { - from { - opacity: 0; - } +export function fetchVideo(id) { + if (videoCache.has(id)) { + return videoCache.get(id); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos.find((video) => video.id === id)); + }, VIDEO_DELAY); + }); + videoCache.set(id, promise); + return promise; } -@keyframes fade-out { - to { - opacity: 0; - } +export function fetchVideoDetails(id) { + if (videoDetailsCache.has(id)) { + return videoDetailsCache.get(id); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos.find((video) => video.id === id)); + }, VIDEO_DETAILS_DELAY); + }); + videoDetailsCache.set(id, promise); + return promise; } +``` + +```js src/router.js hidden +import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, addTransitionType} from "react"; + +export function Router({ children }) { + const [isPending, startTransition] = useTransition(); + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + function navigate(url) { + startTransition(() => { + // Tipo de transição para a causa "nav forward" + addTransitionType('nav-forward'); + go(url); + }); + } + function navigateBack(url) { + startTransition(() => { + // Tipo de transição para a causa "nav back" + addTransitionType('nav-back'); + go(url); + }); + } -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); + useEffect(() => { + function handlePopState() { + // Isso não deve animar porque a restauração tem que ser síncrona. + // Mesmo que seja uma transição. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Noop. A URL já foi atualizada. + }, + }); + }); } -} + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } + return ( + + {children} + + ); } -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } -} +const RouterContext = createContext({ url: "/", params: {} }); -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; +export function useRouter() { + return use(RouterContext); } -::view-transition-new(.slow-fade) { - animation-duration: 500ms; +export function useIsNavPending() { + return use(RouterContext).isPending; } -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` -```json package.json hidden +```json { "dependencies": { "react": "canary", @@ -8829,30 +4115,37 @@ root.render( +Também podemos fornecer animações personalizadas usando um `exit` no fallback e `enter` no conteúdo: -### Animating Lists {/*animating-lists*/} - -You can also use `` to animate lists of items as they re-order, like in a searchable list of items: - -```js {3,5} -
- {filteredVideos.map((video) => ( - - - ))} -
+ } +> + + + + ``` -To activate the ViewTransition, we can use `useDeferredValue`: +Veja como definiremos `slide-down` e `slide-up` com CSS: -```js {2} -const [searchText, setSearchText] = useState(''); -const deferredSearchText = useDeferredValue(searchText); -const filteredVideos = filterVideos(videos, deferredSearchText); +```css {1, 6} +::view-transition-old(.slide-down) { + /* Desliza o fallback para baixo */ + animation: ...; +} + +::view-transition-new(.slide-up) { + /* Desliza o conteúdo para cima */ + animation: ...; +} ``` -Now the items animate as you type in the search bar: +Agora, o conteúdo do Suspense substitui o fallback com uma animação de deslizamento: @@ -8865,7 +4158,7 @@ import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); - // Default slow-fade animation. + // Animação slow-fade padrão. return ( {url === "/" ? :
} @@ -8874,26 +4167,20 @@ export default function App() { } ``` -```js src/Details.js hidden -import { use, Suspense, ViewTransition } from "react"; -import { fetchVideo, fetchVideoDetails } from "./data"; -import { Thumbnail, VideoControls } from "./Videos"; -import { useRouter } from "./router"; -import Layout from "./Layout"; -import { ChevronLeft } from "./Icons"; +```js src/Details.js active +import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; -function VideoDetails({id}) { - // Animate from Suspense fallback to content +function VideoDetails({ id }) { return ( } > - {/* Animate the content up */} + {/* Anima o conteúdo para cima */} @@ -8924,7 +4211,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Voltar
} > @@ -8949,49 +4236,19 @@ function VideoInfo({ id }) { } ``` -```js src/Home.js -import { useId, useState, use, useDeferredValue, ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; - -function SearchList({searchText, videos}) { - // Activate with useDeferredValue ("when") - const deferredSearchText = useDeferredValue(searchText); - const filteredVideos = filterVideos(videos, deferredSearchText); - return ( -
-
- {filteredVideos.map((video) => ( - // Animate each item in list ("what") - - - ))} -
- {filteredVideos.length === 0 && ( -
No results
- )} -
- ); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(''); - - return ( - {count} Videos
}> - - - - ); -} +```js src/Home.js hidden +import { Video } from "./Videos"; +import Layout from "./Layout"; +import { fetchVideos } from "./data"; +import { useId, useState, use } from "react"; +import { IconSearch } from "./Icons"; function SearchInput({ value, onChange }) { const id = useId(); return (
e.preventDefault()}>
@@ -9000,7 +4257,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -9024,7 +4281,28 @@ function filterVideos(videos, query) { return keywords.every((kw) => words.some((w) => w.includes(kw))); }); } -``` + +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(""); + const foundVideos = filterVideos(videos, searchText); + return ( + {count} Vídeos
}> + +
+ {foundVideos.length === 0 && ( +
Nenhum resultado
+ )} +
+ {foundVideos.map((video) => ( +
+
+ + ); +} ```js src/Icons.js hidden export function ChevronLeft() { @@ -9119,7 +4397,7 @@ export function Heart({liked, animate}) { )} @@ -9437,7 +4715,6 @@ export function useRouter() { export function useIsNavPending() { return use(RouterContext).isPending; } - ``` ```css src/styles.css hidden @@ -9983,85 +5260,44 @@ ul { cursor: pointer; border: none; align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; -} - -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} - -/* Home */ -.video-list { - position: relative; -} - -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` - - -```css src/animations.css -/* No additional animations needed */ - - - - - - - - - -/* Previously defined animations below */ - - - - - - -/* Slide animation for Suspense */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; + color: rgb(35 39 47); + border-radius: 9999px; + vertical-align: middle; + font-size: 15px; } -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +.search-input input:hover, .search-input input:active { + background-color: rgb(235 236 240/ 0.8); + color: rgb(35 39 47/ 0.8); } -/* Animations for view transition classed added by transition type */ -::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; +/* Home */ +.video-list { + position: relative; } -::view-transition-new(.slide-forward) { - /* when sliding forward, the "new" page should slide in from right. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; +.video-list .videos { + display: flex; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + height: 100%; } +``` -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; + +```css src/animations.css +/* Slide the fallback down */ +::view-transition-old(.slide-down) { + animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; } -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; +/* Slide the content up */ +::view-transition-new(.slide-up) { + animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; } -/* Keyframes to support our animations above. */ +/* Define the new keyframes */ @keyframes slide-up { from { transform: translateY(10px); @@ -10080,679 +5316,472 @@ ul { } } -@keyframes fade-in { - from { - opacity: 0; - } -} - -@keyframes fade-out { - to { - opacity: 0; - } -} - -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} - -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } -} - -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } -} - -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } -} - - -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` - -```json package.json hidden -{ - "dependencies": { - "react": "canary", - "react-dom": "canary", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - - - -### Final result {/*final-result*/} - -By adding a few `` components and a few lines of CSS, we were able to add all the animations above into the final result. - -We're excited about View Transitions and think they will level up the apps you're able to build. They're ready to start trying today in the experimental channel of React releases. - -Let's remove the slow fade, and take a look at the final result: - - - -```js src/App.js -import {ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; - -export default function App() { - const {url} = useRouter(); - - // Animate with a cross fade between pages. - return ( - - {url === '/' ? :
} - - ); -} -``` - -```js src/Details.js -import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; +/* Previously defined animations below */ -function VideoDetails({id}) { - // Animate from Suspense fallback to content - return ( - - - - } - > - {/* Animate the content up */} - - - - - ); +/* Animations for view transition classed added by transition type */ +::view-transition-old(.slide-forward) { + /* when sliding forward, the "old" page should slide out to left. */ + animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, + 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; } -function VideoInfoFallback() { - return ( - <> -
-
- - ); +::view-transition-new(.slide-forward) { + /* when sliding forward, the "new" page should slide in from right. */ + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, + 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; } -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); - - return ( - { - navigateBack("/"); - }} - > - Back -
- } - > -
- - - - -
- - ); +::view-transition-old(.slide-back) { + /* when sliding back, the "old" page should slide out to right. */ + animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, + 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); +::view-transition-new(.slide-back) { + /* when sliding back, the "new" page should slide in from left. */ + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, + 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; } -``` - -```js src/Home.js -import { useId, useState, use, useDeferredValue, ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; -function SearchList({searchText, videos}) { - // Activate with useDeferredValue ("when") - const deferredSearchText = useDeferredValue(searchText); - const filteredVideos = filterVideos(videos, deferredSearchText); - return ( -
-
- {filteredVideos.map((video) => ( - // Animate each item in list ("what") - - - ))} -
- {filteredVideos.length === 0 && ( -
No results
- )} -
- ); +/* Keyframes to support our animations above. */ +@keyframes fade-in { + from { + opacity: 0; + } } -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(''); +@keyframes fade-out { + to { + opacity: 0; + } +} - return ( - {count} Videos
}> - - - - ); +@keyframes slide-to-right { + to { + transform: translateX(50px); + } } -function SearchInput({ value, onChange }) { - const id = useId(); - return ( - e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
- - ); +@keyframes slide-from-right { + from { + transform: translateX(50px); + } + to { + transform: translateX(0); + } } -function filterVideos(videos, query) { - const keywords = query - .toLowerCase() - .split(" ") - .filter((s) => s !== ""); - if (keywords.length === 0) { - return videos; - } - return videos.filter((video) => { - const words = (video.title + " " + video.description) - .toLowerCase() - .split(" "); - return keywords.every((kw) => words.some((w) => w.includes(kw))); - }); +@keyframes slide-to-left { + to { + transform: translateX(-50px); + } } -``` -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); +@keyframes slide-from-left { + from { + transform: translateX(-50px); + } + to { + transform: translateX(0); + } } -export function PauseIcon() { - return ( - - - - ); +/* Default .slow-fade. */ +::view-transition-old(.slow-fade) { + animation-duration: 500ms; } -export function PlayIcon() { - return ( - - - - ); +::view-transition-new(.slow-fade) { + animation-duration: 500ms; } -export function Heart({liked, animate}) { - return ( - <> - - - +``` - - {liked ? ( - - ) : ( - - )} - - - ); +```js src/index.js hidden +import React, {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import './styles.css'; +import './animations.css'; + +import App from './App'; +import {Router} from './router'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + + + +); +``` + +```json package.json hidden +{ + "dependencies": { + "react": "canary", + "react-dom": "canary", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } } +``` -export function IconSearch(props) { + +### Animação de Listas {/*animating-lists*/} + +Você também pode usar `` para animar listas de itens enquanto elas se reordenam, como em uma lista de itens pesquisável: + +```js {3,5} +
+ {filteredVideos.map((video) => ( + + + ))} +
+``` + +Para ativar o ViewTransition, podemos usar `useDeferredValue`: + +```js {2} +const [searchText, setSearchText] = useState(''); +const deferredSearchText = useDeferredValue(searchText); +const filteredVideos = filterVideos(videos, deferredSearchText); +``` + +Agora os itens animam enquanto você digita na barra de pesquisa: + + + +```js src/App.js hidden +import { ViewTransition } from "react"; +import Details from "./Details"; +import Home from "./Home"; +import {useRouter} from "./router"; + +export default function App() { + const { url } = useRouter(); + + // Animação padrão de fade lento. return ( - - - + + {url === "/" ? :
} + ); } ``` -```js src/Layout.js -import {ViewTransition} from 'react'; import { useIsNavPending } from "./router"; +```js src/Details.js hidden +import { use, Suspense, ViewTransition } from "react"; +import { fetchVideo, fetchVideoDetails } from "./data"; +import { Thumbnail, VideoControls } from "./Videos"; +import { useRouter } from "./router"; +import Layout from "./Layout"; +import { ChevronLeft } from "./Icons"; -export default function Page({ heading, children }) { - const isPending = useIsNavPending(); +function VideoDetails({id}) { + // Animação do fallback do Suspense para o conteúdo return ( -
-
-
- {/* Custom classes based on transition type. */} - - {heading} - - {isPending && } -
-
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} - -
-
{children}
-
+ + +
+ } + > + {/* Animação do conteúdo para cima */} + + -
+ ); } -``` - -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; - -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); +function VideoInfoFallback() { return ( - + <> +
+
+ ); } -``` -```js src/Videos.js -import { useState, ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react"; +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); -export function Thumbnail({ video, children }) { - // Add a name to animate with a shared element transition. return ( - - + } + > +
+ + + +
-
+ ); } - - -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); - +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - + <> +

{details.title}

+

{details.description}

+ ); } +``` -export function Video({ video }) { - const { navigate } = useRouter(); +```js src/Home.js +import { useId, useState, use, useDeferredValue, ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; +function SearchList({searchText, videos}) { + // Ativa com useDeferredValue ("quando") + const deferredSearchText = useDeferredValue(searchText); + const filteredVideos = filterVideos(videos, deferredSearchText); return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - - -
-
{video.title}
-
{video.description}
-
+
+
+ {filteredVideos.map((video) => ( + // Anima cada item da lista ("o quê") + + + ))}
- + {filteredVideos.length === 0 && ( +
Nenhum resultado
+ )}
); } -``` +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); -```js src/data.js hidden -const videos = [ - { - id: '1', - title: 'First video', - description: 'Video description', - image: 'blue', - }, - { - id: '2', - title: 'Second video', - description: 'Video description', - image: 'red', - }, - { - id: '3', - title: 'Third video', - description: 'Video description', - image: 'green', - }, - { - id: '4', - title: 'Fourth video', - description: 'Video description', - image: 'purple', - }, - { - id: '5', - title: 'Fifth video', - description: 'Video description', - image: 'yellow', - }, - { - id: '6', - title: 'Sixth video', - description: 'Video description', - image: 'gray', - }, -]; - -let videosCache = new Map(); -let videoCache = new Map(); -let videoDetailsCache = new Map(); -const VIDEO_DELAY = 1; -const VIDEO_DETAILS_DELAY = 1000; -export function fetchVideos() { - if (videosCache.has(0)) { - return videosCache.get(0); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos); - }, VIDEO_DELAY); - }); - videosCache.set(0, promise); - return promise; + return ( + {count} Vídeos
}> + + + + ); } -export function fetchVideo(id) { - if (videoCache.has(id)) { - return videoCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DELAY); - }); - videoCache.set(id, promise); - return promise; +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); } -export function fetchVideoDetails(id) { - if (videoDetailsCache.has(id)) { - return videoDetailsCache.get(id); +function filterVideos(videos, query) { + const keywords = query + .toLowerCase() + .split(" ") + .filter((s) => s !== ""); + if (keywords.length === 0) { + return videos; } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DETAILS_DELAY); + return videos.filter((video) => { + const words = (video.title + " " + video.description) + .toLowerCase() + .split(" "); + return keywords.every((kw) => words.some((w) => w.includes(kw))); }); - videoDetailsCache.set(id, promise); - return promise; } ``` -```js src/router.js -import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, addTransitionType} from "react"; - -export function Router({ children }) { - const [isPending, startTransition] = useTransition(); - function navigate(url) { - startTransition(() => { - // Transition type for the cause "nav forward" - addTransitionType('nav-forward'); - go(url); - }); - } - function navigateBack(url) { - startTransition(() => { - // Transition type for the cause "nav backward" - addTransitionType('nav-back'); - go(url); - }); - } - - const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - - useEffect(() => { - function handlePopState() { - // This should not animate because restoration has to be synchronous. - // Even though it's a transition. - startTransition(() => { - setRouterState({ - url: document.location.pathname + document.location.search, - pendingNav() { - // Noop. URL has already updated. - }, - }); - }); - } - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - const pendingNav = routerState.pendingNav; - useLayoutEffect(() => { - pendingNav(); - }, [pendingNav]); +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); +} +export function PauseIcon() { return ( - - {children} - + + + ); } -const RouterContext = createContext({ url: "/", params: {} }); +export function PlayIcon() { + return ( + + + + ); +} +export function Heart({liked, animate}) { + return ( + <> + + + -export function useRouter() { - return use(RouterContext); + + {liked ? ( + + ) : ( + + )} + + + ); } -export function useIsNavPending() { - return use(RouterContext).isPending; +export function IconSearch(props) { + return ( + + + + ); } +``` +```js src/Layout.js hidden +import {ViewTransition} from 'react'; +import { useIsNavPending } from "./router"; + +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Classes personalizadas baseadas no tipo de transição. */} + + {heading} + + {isPending && } +
+
+ {/* Opta por não usar ViewTransition para o conteúdo. */} + {/* O conteúdo pode definir seu próprio ViewTransition. */} + +
+
{children}
+
+
+
+ ); +} ``` +```js src/Like + ```css src/styles.css hidden @font-face { font-family: Optimistic Text; @@ -11312,18 +6341,35 @@ ul { position: relative; } -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` +.video-list .videos { + display: flex; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + height: 100%; +} +``` + + +```css src/animations.css +/* No additional animations needed */ + + + + + + + + + +/* Previously defined animations below */ + + + + -```css src/animations.css -/* Slide animations for Suspense the fallback down */ +/* Slide animation for Suspense */ ::view-transition-old(.slide-down) { animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; } @@ -11417,6 +6463,16 @@ ul { transform: translateX(0); } } + + +/* Default .slow-fade. */ +::view-transition-old(.slow-fade) { + animation-duration: 500ms; +} + +::view-transition-new(.slow-fade) { + animation-duration: 500ms; +} ``` ```js src/index.js hidden @@ -11456,27 +6512,236 @@ root.render( -If you're curious to know more about how they work, check out [How Does `` Work](/reference/react/ViewTransition#how-does-viewtransition-work) in the docs. +### Resultado final {/*final-result*/} + +Ao adicionar alguns componentes `` e algumas linhas de CSS, conseguimos adicionar todas as animações acima ao resultado final. + +Estamos animados com as Transições de Visualização e achamos que elas elevarão os aplicativos que você poderá criar. Elas estão prontas para você começar a experimentar hoje no canal experimental das versões do React. + +Vamos remover o fade lento e dar uma olhada no resultado final: + + + +```js src/App.js +import {ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; + +export default function App() { + const {url} = useRouter(); + + // Anima com um cross fade entre páginas. + return ( + + {url === '/' ? :
} + + ); +} +``` + +```js src/Details.js +import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; + +function VideoDetails({id}) { + // Anima do fallback do Suspense para o conteúdo + return ( + + + + } + > + {/* Anima o conteúdo para cima */} + + + + + ); +} + +function VideoInfoFallback() { + return ( + <> +
+
+ + ); +} + +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); + + return ( + { + navigateBack("/"); + }} + > + Voltar +
+ } + > +
+ + + + +
+ + ); +} + +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); + return ( + <> +

{details.title}

+

{details.description}

+ + ); +} +``` + +```js src/Home.js +import { useId, useState, use, useDeferredValue, ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; + +function SearchList({searchText, videos}) { + // Ativa com useDeferredValue ("quando") + const deferredSearchText = useDeferredValue(searchText); + const filteredVideos = filterVideos(videos, deferredSearchText); + return ( +
+
+ {filteredVideos.map((video) => ( + // Anima cada item da lista ("o quê") + + + ))} +
+ {filteredVideos.length === 0 && ( +
Nenhum resultado
+ )} +
+ ); +} + +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); + + return ( + {count} Vídeos}> + + + + ); +} + +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); +} + +function filterVideos(videos, query) { + const keywords = query + .toLowerCase() + .split(" ") + .filter((s) => s !== ""); + if (keywords.length === 0) { + return videos; + } + return videos.filter((video) => { + const words = (video.title + " " + video.description) + .toLowerCase() + .split(" "); + return keywords.every((kw) => words.some((w) => w.includes(kw))); + }); +} +``` + +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); +} + +export function PauseIcon() { + return ( + + @@ -11484,25 +6749,25 @@ We're now ready to share the API and how it works, so you can start testing it i ``` -When an Activity is visible it's rendered as normal. When an Activity is hidden it is unmounted, but will save its state and continue to render at a lower priority than anything visible on screen. +Quando uma Atividade está visível, ela é renderizada normalmente. Quando uma Atividade está oculta, ela é desmontada, mas salvará seu estado e continuará a renderizar com menor prioridade do que qualquer coisa visível na tela. -You can use `Activity` to save state for parts of the UI the user isn't using, or pre-render parts that a user is likely to use next. +Você pode usar `Activity` para salvar o estado de partes da UI que o usuário não está usando, ou pré-renderizar partes que o usuário provavelmente usará em seguida. -Let's look at some examples improving the View Transition examples above. +Vamos ver alguns exemplos aprimorando os exemplos de Transição de Visualização acima. -**Effects don’t mount when an Activity is hidden.** +**Efeitos não são montados quando uma Atividade está oculta.** -When an `` is `hidden`, Effects are unmounted. Conceptually, the component is unmounted, but React saves the state for later. +Quando um `` está `hidden`, os Efeitos são desmontados. Conceitualmente, o componente é desmontado, mas o React salva o estado para mais tarde. -In practice, this works as expected if you have followed the [You Might Not Need an Effect](/learn/you-might-not-need-an-effect) guide. To eagerly find problematic Effects, we recommend adding [``](/reference/react/StrictMode) which will eagerly perform Activity unmounts and mounts to catch any unexpected side effects. +Na prática, isso funciona como esperado se você seguiu o guia [Você Pode Não Precisar de um Efeito](/learn/you-might-not-need-an-effect). Para encontrar Efeitos problemáticos de forma proativa, recomendamos adicionar [``](/reference/react/StrictMode), que realizará proativamente desmontagens e montagens de Atividade para capturar quaisquer efeitos colaterais inesperados. -### Restoring state with Activity {/*restoring-state-with-activity*/} +### Restaurando estado com Activity {/*restoring-state-with-activity*/} -When a user navigates away from a page, it's common to stop rendering the old page: +Quando um usuário sai de uma página, é comum parar de renderizar a página antiga: ```js {6,7} function App() { @@ -11517,9 +6782,9 @@ function App() { } ``` -However, this means if the user goes back to the old page, all of the previous state is lost. For example, if the `` page has an `
{filteredVideos.length === 0 && ( -
No results
+
Nenhum resultado
)}
{filteredVideos.map((video) => ( - // Animate each item in list ("what") + // Anima cada item da lista ("o quê") @@ -11666,7 +6931,7 @@ export default function Home() { const [searchText, setSearchText] = useState(''); return ( - {count} Videos
}> + {count} Vídeos
}> @@ -11678,7 +6943,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -11687,7 +6952,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -11840,7 +7105,7 @@ export default function Page({ heading, children }) {
- {/* Custom classes based on transition type. */} + {/* Classes personalizadas baseadas no tipo de transição. */} }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Opta por não usar ViewTransition para o conteúdo. */} + {/* O conteúdo pode definir seu próprio ViewTransition. */}
{children}
@@ -11868,8 +7133,8 @@ export default function Page({ heading, children }) { import {useState} from 'react'; import {Heart} from './Icons'; -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. +// Uma gambiarra, já que não temos um backend de verdade. +// Diferente do estado local, isso sobrevive à filtragem de vídeos. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -11878,7 +7143,7 @@ export default function LikeButton({video}) { return (