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 e4bb25a4aa..bc8a314c0f 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 @@ -1,37 +1,39 @@ --- -title: "React Labs: View Transitions, Activity, and more" +title: "React Labs: Переходы представлений, активность и многое другое" 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: В публикациях React Labs мы пишем о проектах, находящихся в стадии + активных исследований и разработок. В этой публикации мы расскажем о двух + новых экспериментальных возможностях, готовых к тестированию сегодня, а также + об обновлениях в других областях, над которыми мы работаем сейчас. --- - -April 23, 2025 by [Ricky Hanlon](https://twitter.com/rickhanlonii) +23 апреля 2025 г. от [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. +В публикациях React Labs мы пишем о проектах, находящихся в активной стадии исследований и разработок. В этой публикации мы представляем две новые экспериментальные функции, готовые к тестированию, а также обновления по другим областям, над которыми мы сейчас работаем. -React Conf 2025 is scheduled for October 7–8 in Henderson, Nevada! +React Conf 2025 запланирована на 7–8 октября в Хендерсоне, Невада! -We're looking for speakers to help us create talks about the features covered in this post. If you're interested in speaking at ReactConf, [please apply here](https://forms.reform.app/react-conf/call-for-speakers/) (no talk proposal required). +Мы ищем докладчиков, которые помогут нам создать выступления о функциях, описанных в этой публикации. Если вы заинтересованы в выступлении на ReactConf, [подайте заявку здесь](https://forms.reform.app/react-conf/call-for-speakers/) (предварительное предложение доклада не требуется). -For more info on tickets, free streaming, sponsoring, and more, see [the React Conf website](https://conf.react.dev). +Для получения дополнительной информации о билетах, бесплатной трансляции, спонсорстве и многом другом посетите [веб-сайт React Conf](https://conf.react.dev). -Today, we're excited to release documentation for two new experimental features that are ready for testing: +Сегодня мы рады опубликовать документацию для двух новых экспериментальных функций, готовых к тестированию: - [View Transitions](#view-transitions) - [Activity](#activity) -We're also sharing updates on new features currently in development: +Мы также делимся обновлениями о новых функциях, находящихся в разработке: - [React Performance Tracks](#react-performance-tracks) - [Compiler IDE Extension](#compiler-ide-extension) - [Automatic Effect Dependencies](#automatic-effect-dependencies) @@ -40,57 +42,57 @@ We're also sharing updates on new features currently in development: --- -# New Experimental Features {/*new-experimental-features*/} +# Новые экспериментальные функции {/*new-experimental-features*/} -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 и Activity теперь готовы к тестированию в `react@experimental`. Эти функции были протестированы в продакшене и являются стабильными, но окончательный API может измениться по мере внесения обратной связи. -You can try them by upgrading React packages to the most recent experimental version: +Вы можете попробовать их, обновив пакеты React до последней экспериментальной версии: - `react@experimental` - `react-dom@experimental` -Read on to learn how to use these features in your app, or check out the newly published docs: +Читайте дальше, чтобы узнать, как использовать эти функции в вашем приложении, или ознакомьтесь с недавно опубликованной документацией: -- [``](/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): Компонент, который позволяет активировать анимацию для перехода. +- [`addTransitionType`](/reference/react/addTransitionType): Функция, которая позволяет указать причину перехода. +- [``](/reference/react/Activity): Компонент, который позволяет скрывать и отображать части пользовательского интерфейса. -## View Transitions {/*view-transitions*/} +## Переходы между видами {/*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. +Переходы между видами в React — это новая экспериментальная функция, которая упрощает добавление анимаций при переходе между состояниями пользовательского интерфейса в вашем приложении. По сути, эти анимации используют новый API [`startViewTransition`](https://developer.mozilla.org/ru/docs/Web/API/Document/startViewTransition), доступный в большинстве современных браузеров. -To opt-in to animating an element, wrap it in the new `` component: +Чтобы включить анимацию элемента, оберните его в новый компонент ``: ```js -// "what" to animate. +// "Что" анимировать. -
animate me
+
Анимируй меня
``` -This new component lets you declaratively define "what" to animate when an animation is activated. +Этот новый компонент позволяет декларативно определить, "что" будет анимировано при активации анимации. -You can define "when" to animate by using one of these three triggers for a View Transition: +Вы можете определить, "когда" анимировать, используя один из следующих трех триггеров для перехода между видами: ```js -// "when" to animate. +// "Когда" анимировать. -// Transitions +// Переходы startTransition(() => setState(...)); -// Deferred Values +// Отложенные значения const deferred = useDeferredValue(value); // Suspense }> -
Loading...
+
Загрузка...
``` -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: +По умолчанию эти анимации используют [CSS-анимации по умолчанию для переходов между видами](https://developer.mozilla.org/ru/docs/Web/API/View_Transition_API/Using#customizing_your_animations) (обычно плавное перекрестное затухание). Вы можете использовать [псевдоселекторы переходов между видами](https://developer.mozilla.org/ru/docs/Web/API/View_Transition_API/Using#the_view_transition_pseudo-element_tree), чтобы определить, "как" будет выполняться анимация. Например, вы можете использовать `*`, чтобы изменить анимацию по умолчанию для всех переходов: ``` -// "how" to animate. +// "Как" анимировать. ::view-transition-old(*) { animation: 300ms ease-out fade-out; } @@ -99,16 +101,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. +Когда DOM обновляется из-за триггера анимации — например, `startTransition`, `useDeferredValue` или переключение резервного варианта `Suspense` на контент — React будет использовать [декларативные эвристики](/reference/react/ViewTransition#viewtransition), чтобы автоматически определить, какие компоненты `` следует активировать для анимации. Затем браузер выполнит анимацию, определенную в 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. +Если вы знакомы с API переходов между видами в браузере и хотите узнать, как React его поддерживает, ознакомьтесь с разделом [Как работает ``](/reference/react/ViewTransition#how-does-viewtransition-work) в документации. -In this post, let's take a look at a few examples of how to use View Transitions. +В этой статье мы рассмотрим несколько примеров использования переходов между видами. -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. +Мы начнем с этого приложения, которое не анимирует ни одно из следующих взаимодействий: +- Нажмите на видео, чтобы просмотреть детали. +- Нажмите "назад", чтобы вернуться к ленте. +- Введите текст в списке, чтобы отфильтровать видео. @@ -118,7 +120,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 + // 🚩Эта версия еще не включает никаких анимаций return url === '/' ? : ; } ``` @@ -164,7 +166,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад } > @@ -194,7 +196,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -203,7 +205,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -234,11 +236,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos
}> + {count} Видео
}>
{foundVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{foundVideos.map((video) => ( @@ -345,7 +347,7 @@ export function Heart({liked, animate}) { )} @@ -396,8 +398,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. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -406,7 +408,7 @@ export default function LikeButton({video}) { return (
+ } + > +
+ + + + }> + + +
+ + ); } +``` -/* Layout */ -.page { - display: flex; - flex-direction: column; - height: 100%; -} +```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"; -.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": "experimental", - "react-dom": "experimental", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - - - - - -#### View Transitions do not replace CSS and JS driven animations {/*view-transitions-do-not-replace-css-and-js-driven-animations*/} - -View Transitions are meant to be used for UI transitions such as navigation, expanding, opening, or re-ordering. They are not meant to replace all the animations in your app. - -In our example app above, notice that there are already animations when you click the "like" button and in the Suspense fallback glimmer. These are good use cases for CSS animations because they are animating a specific element. - - - -### Animating navigations {/*animating-navigations*/} - -Our app includes a Suspense-enabled router, with [page transitions already marked as Transitions](/reference/react/useTransition#building-a-suspense-enabled-router), which means navigations are performed with `startTransition`: - -```js -function navigate(url) { - startTransition(() => { - go(url); - }); -} -``` - -`startTransition` is a View Transition trigger, so we can add `` to animate between pages: - -```js -// "what" to animate - - {url === '/' ? : } - -``` - -When the `url` changes, the `` and new route are rendered. Since the `` was updated inside of `startTransition`, the `` is activated for an animation. - - -By default, View Transitions include the browser default cross-fade animation. Adding this to our example, we now have a cross-fade whenever we navigate between pages: - - - -```js src/App.js active -import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; - -export default function App() { - const {url} = useRouter(); - - // Use ViewTransition to animate between pages. - // No additional CSS needed by default. - 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 -import {unstable_ViewTransition as 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": "experimental", - "react-dom": "experimental", - "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-classes): - -```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 { unstable_ViewTransition as 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 definiton. - 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 {unstable_ViewTransition as 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": "experimental", - "react-dom": "experimental", - "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 { unstable_ViewTransition as 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 {unstable_ViewTransition as 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, unstable_ViewTransition as 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": "experimental", - "react-dom": "experimental", - "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 { unstable_ViewTransition as 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 SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
); } @@ -5090,11 +837,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos}> + {count} Видео}>
{foundVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{foundVideos.map((video) => ( @@ -5201,7 +948,7 @@ export function Heart({liked, animate}) { )} @@ -5226,29 +973,22 @@ export function IconSearch(props) { } ``` -```js src/Layout.js active +```js src/Layout.js import {unstable_ViewTransition as 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. */} + {/* Отключить ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -5263,8 +1003,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. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -5273,7 +1013,7 @@ export default function LikeButton({video}) { return (
+ } + > +
+ + + + }> + + +
+ + ); } -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px +``` + +```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)} + /> +
+
+ ); } -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; +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))); + }); } -.back:hover { - text-decoration: underline; +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(""); + const foundVideos = filterVideos(videos, searchText); + return ( + {count} Видео
}> + +
+ {foundVideos.length === 0 && ( +
Нет результатов
+ )} +
+ {foundVideos.map((video) => ( +
+
+ + ); } -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; +``` + +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); } -.info-description { - margin: 8px 0 0 0; +export function PauseIcon() { + return ( + + + + ); } -.controls { - cursor: pointer; +export function PlayIcon() { + return ( + + + + ); } +export function Heart({liked, animate}) { + return ( + <> + + + -.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; + + {liked ? ( + + ) : ( + + )} + + + ); } - -.fallback.title { - width: 130px; - height: 30px; - +export function IconSearch(props) { + return ( + + + + ); } +``` -.fallback.description { - width: 150px; - height: 21px; -} +```js src/Layout.js hidden +import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; -@keyframes shimmer { - 0% { - background-position: -468px 0; - } +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); - 100% { - background-position: 468px 0; - } + return ( +
+
+
+ {heading} + {isPending && } +
+
+ {/* Опт-аут из ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); } +``` -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; -.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; -} +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. +const likedVideos = new Set(); -.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 default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + 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/Videos.js hidden +import { useState } from "react"; +import LikeButton from "./LikeButton"; +import { useRouter } from "./router"; +import { PauseIcon, PlayIcon } from "./Icons"; +import { startTransition } from "react"; -/* Home */ -.video-list { - position: relative; +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); + + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); } -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; +export function Thumbnail({ video, children }) { + return ( + + ); } -``` +export function Video({ video }) { + const { navigate } = useRouter(); -```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; -} + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + -::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.title}
+
{video.description}
+
+
+ +
+ ); } +``` -::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; -} +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'Первое видео', + description: 'Описание видео', + image: 'blue', + }, + { + id: '2', + title: 'Второе видео', + description: 'Описание видео', + image: 'red', + }, + { + id: '3', + title: 'Третье видео', + description: 'Описание видео', + image: 'green', + }, + { + id: '4', + title: 'Четвертое видео', + description: 'Описание видео', + image: 'purple', + }, + { + id: '5', + title: 'Пятое видео', + description: 'Описание видео', + image: 'yellow', + }, + { + id: '6', + title: 'Шестое видео', + description: 'Описание видео', + image: 'gray', + }, +]; -/* New keyframes to support our animations above. */ -@keyframes fade-in { - from { - opacity: 0; - } +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 fade-out { - to { - 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 slide-to-right { - to { - transform: translateX(50px); - } +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; } +``` -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } -} +```js src/router.js hidden +import { + useState, + createContext, + use, + useTransition, + useLayoutEffect, + useEffect, +} from "react"; -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } -} +const RouterContext = createContext({ url: "/", params: {} }); -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } +export function useRouter() { + return use(RouterContext); } -/* Previously defined animations. */ - -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; +export function useIsNavPending() { + return use(RouterContext).isPending; } -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` +export function Router({ children }) { + const [routerState, setRouterState] = useState({ + pendingNav: () => {}, + url: document.location.pathname, + }); + const [isPending, startTransition] = useTransition(); -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + function navigate(url) { + // Обновляем состояние роутера в переходе. + startTransition(() => { + go(url); + }); + } -import App from './App'; -import {Router} from './router'; + function navigateBack(url) { + // Обновляем состояние роутера в переходе. + startTransition(() => { + go(url); + }); + } -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` + useEffect(() => { + function handlePopState() { + // Это не должно анимироваться, так как восстановление должно быть синхронным. + // Даже если это переход. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Ничего не делаем. URL уже обновлен. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); -```json package.json hidden -{ - "dependencies": { - "react": "experimental", - "react-dom": "experimental", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } + return ( + + {children} + + ); } -``` - -### Animating Suspense Boundaries {/*animating-suspense-boundaries*/} +См. [Стилизация переходов представления](/reference/react/ViewTransition#styling-view-transitions) для полного руководства по стилизации ``. + +### Переходы с общими элементами {/*shared-element-transitions*/} -Suspense will also activate View Transitions. +Когда две страницы содержат один и тот же элемент, часто хочется анимировать его переход с одной страницы на другую. -To animate the fallback to content, we can wrap `Suspense` with ``: +Для этого вы можете добавить уникальное `name` к ``: ```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 +```js src/App.js import { unstable_ViewTransition as ViewTransition } from "react"; import Details from "./Details"; import Home from "./Home"; @@ -6238,7 +2500,9 @@ import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); - // Default slow-fade animation. + // Сохраняем наш стандартный медленный переход. + // Это позволяет контенту, не участвующему в переходе с общими элементами, + // плавно переходить. return ( {url === "/" ? :
} @@ -6247,26 +2511,30 @@ export default function App() { } ``` -```js src/Details.js active -import { use, Suspense, unstable_ViewTransition as 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 }) { - // Cross-fade the fallback to content. +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); return ( - - }> - - - + <> +

{details.title}

+

{details.description}

+ ); } -function VideoInfoFallback() { - return ( -
-
-
-
+function VideoInfoFallback() { + return ( + <> +
+
+ ); } @@ -6284,7 +2552,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад
} > @@ -6292,21 +2560,14 @@ export default function Details() { - + }> + +
); } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( -
-

{details.title}

-

{details.description}

-
- ); -} ``` ```js src/Home.js hidden @@ -6321,7 +2582,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -6330,7 +2591,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -6361,11 +2622,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos
}> + {count} Видео
}>
{foundVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{foundVideos.map((video) => ( @@ -6498,29 +2759,21 @@ export function IconSearch(props) { ``` ```js src/Layout.js hidden -import {unstable_ViewTransition as ViewTransition} from 'react'; -import { useIsNavPending } from "./router"; +import {unstable_ViewTransition as 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. */} + {/* Отключаем ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -6535,8 +2788,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. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -6545,7 +2798,7 @@ export default function LikeButton({video}) { return (
); } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} ``` ```js src/Home.js hidden @@ -7642,7 +3834,7 @@ function SearchInput({ value, onChange }) { return ( e.preventDefault()}>
@@ -7651,7 +3843,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -7682,11 +3874,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos
}> + {count} Видео
}>
{foundVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{foundVideos.map((video) => ( @@ -7818,9 +4010,8 @@ export function IconSearch(props) { } ``` -```js src/Layout.js hidden -import {unstable_ViewTransition as ViewTransition} from 'react'; -import { useIsNavPending } from "./router"; +```js src/Layout.js active +import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; export default function Page({ heading, children }) { const isPending = useIsNavPending(); @@ -7828,7 +4019,7 @@ export default function Page({ heading, children }) {
- {/* Custom classes based on transition type. */} + {/* Пользовательские классы в зависимости от типа перехода. */} }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Отключение ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -7856,8 +4047,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. +// Хак, так как у нас на самом деле нет бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -7866,7 +4057,7 @@ export default function LikeButton({video}) { return (
}> + +
+ {foundVideos.length === 0 && ( +
Нет результатов
+ )} +
+ {foundVideos.map((video) => ( +
+
+ + ); +} -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); ``` -```json package.json hidden -{ - "dependencies": { - "react": "experimental", - "react-dom": "experimental", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); } -``` - - +export function PauseIcon() { + return ( + + + + ); +} -### Animating Lists {/*animating-lists*/} +export function PlayIcon() { + return ( + + + + ); +} +export function Heart({liked, animate}) { + return ( + <> + + + -You can also use `` to animate lists of items as they re-order, like in a searchable list of items: + + {liked ? ( + + ) : ( + + )} + + + ); +} -```js {3,5} -
- {filteredVideos.map((video) => ( - - - ))} -
+export function IconSearch(props) { + return ( + + + + ); +} ``` -To activate the ViewTransition, we can use `useDeferredValue`: +```js src/Layout.js hidden +import {unstable_ViewTransition as ViewTransition} from 'react'; +import {useIsNavPending} from "./router"; -```js {2} -const [searchText, setSearchText] = useState(''); -const deferredSearchText = useDeferredValue(searchText); -const filteredVideos = filterVideos(videos, deferredSearchText); +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Пользовательские классы в зависимости от типа перехода. */} + + {heading} + + {isPending && } +
+
+ {/* Отказ от ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); +} ``` -Now the items animate as you type in the search bar: - - - -```js src/App.js hidden -import { unstable_ViewTransition as ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; -export default function App() { - const { url } = useRouter(); +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. +const likedVideos = new Set(); - // Default slow-fade animation. +export default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); return ( - - {url === "/" ? :
} - + ); } ``` -```js src/Details.js hidden -import { use, Suspense, unstable_ViewTransition as 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/Videos.js hidden +import {useState, unstable_ViewTransition as ViewTransition} from "react"; +import LikeButton from "./LikeButton"; +import {useRouter} from "./router"; +import {PauseIcon, PlayIcon} from "./Icons"; +import {startTransition} from "react"; -function VideoDetails({id}) { - // Animate from Suspense fallback to content +export function Thumbnail({video, children}) { + // Добавьте имя для анимации с помощью перехода с общим элементом. + // Это использует анимацию по умолчанию, дополнительный CSS не требуется. return ( - - - - } - > - {/* Animate the content up */} - - - - + + + ); } -function VideoInfoFallback() { +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); + return ( - <> -
-
- + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + ); } -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); +export function Video({video}) { + const {navigate} = useRouter(); return ( - { - navigateBack("/"); - }} - > - Back +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + + +
+
{video.title}
+
{video.description}
- } - > -
- - - -
- + +
); } +``` + + +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'Первое видео', + description: 'Описание видео', + image: 'blue', + }, + { + id: '2', + title: 'Второе видео', + description: 'Описание видео', + image: 'red', + }, + { + id: '3', + title: 'Третье видео', + description: 'Описание видео', + image: 'green', + }, + { + id: '4', + title: 'Четвертое видео', + description: 'Описание видео', + image: 'purple', + }, + { + id: '5', + title: 'Пятое видео', + description: 'Описание видео', + image: 'yellow', + }, + { + id: '6', + title: 'Шестое видео', + 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, unstable_addTransitionType as addTransitionType} from "react"; + +export function Router({ children }) { + const [isPending, startTransition] = useTransition(); + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + function navigate(url) { + startTransition(() => { + // Тип перехода для причины "переход вперед" + addTransitionType('nav-forward'); + go(url); + }); + } + function navigateBack(url) { + startTransition(() => { + // Тип перехода для причины "переход назад" + addTransitionType('nav-back'); + go(url); + }); + } + + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + + useEffect(() => { + function handlePopState() { + // Это не должно анимироваться, так как восстановление должно быть синхронным. + // Даже если это переход. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Нет действия. URL уже обновлен. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); return ( - <> -

{details.title}

-

{details.description}

- + + {children} + ); } -``` -```js src/Home.js -import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; +const RouterContext = createContext({ url: "/", params: {} }); -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 function useRouter() { + return use(RouterContext); } -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(''); - - return ( - {count} Videos
}> - - -
- ); +export function useIsNavPending() { + return use(RouterContext).isPending; } -function SearchInput({ value, onChange }) { - const id = useId(); - return ( -
e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
-
- ); + +```css {1, 6} +::view-transition-old(.slide-down) { + /* Сдвинуть резервный вариант вниз */ + animation: ...; } -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))); - }); +::view-transition-new(.slide-up) { + /* Сдвинуть контент вверх */ + animation: ...; } ``` @@ -9120,7 +5677,7 @@ export function Heart({liked, animate}) { )} @@ -9438,7 +5995,6 @@ export function useRouter() { export function useIsNavPending() { return use(RouterContext).isPending; } - ``` ```css src/styles.css hidden @@ -9931,1545 +6487,2163 @@ ul { } -.fallback.title { - width: 130px; - height: 30px; - +.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 +/* Slide the fallback down */ +::view-transition-old(.slide-down) { + animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; +} + +/* Slide the content up */ +::view-transition-new(.slide-up) { + animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +} + +/* Define the new keyframes */ +@keyframes slide-up { + from { + transform: translateY(10px); + } + to { + transform: translateY(0); + } +} + +@keyframes slide-down { + from { + transform: translateY(0); + } + to { + transform: translateY(10px); + } +} + +/* Previously defined animations below */ + +/* 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; +} + +/* 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); + } +} + +/* Default .slow-fade. */ +::view-transition-old(.slow-fade) { + animation-duration: 500ms; } -.fallback.description { - width: 150px; - height: 21px; +::view-transition-new(.slow-fade) { + animation-duration: 500ms; } +``` -@keyframes shimmer { - 0% { - background-position: -468px 0; - } +```js src/index.js hidden +import React, {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import './styles.css'; +import './animations.css'; - 100% { - background-position: 468px 0; - } -} +import App from './App'; +import {Router} from './router'; -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} +const root = createRoot(document.getElementById('root')); +root.render( + + + + + +); +``` -.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; +```json package.json hidden +{ + "dependencies": { + "react": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } } +``` -.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); -} +### Анимация списков {/*animating-lists*/} -/* Home */ -.video-list { - position: relative; -} +Вы также можете использовать `` для анимации списков элементов при их переупорядочивании, например, в списке элементов с возможностью поиска: -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} +```js {3,5} +
+ {filteredVideos.map((video) => ( + + + ))} +
``` +Чтобы активировать ViewTransition, мы можем использовать `useDeferredValue`: -```css src/animations.css -/* No additional animations needed */ +```js {2} +const [searchText, setSearchText] = useState(''); +const deferredSearchText = useDeferredValue(searchText); +const filteredVideos = filterVideos(videos, deferredSearchText); +``` +Теперь элементы анимируются по мере ввода текста в строке поиска: + +```js src/App.js hidden +import { unstable_ViewTransition as ViewTransition } from "react"; +import Details from "./Details"; +import Home from "./Home"; +import {useRouter} from "./router"; +export default function App() { + const { url } = useRouter(); + // Анимация по умолчанию с медленным затуханием. + return ( + + {url === "/" ? :
} + + ); +} +``` +```js src/Details.js hidden +import { use, Suspense, unstable_ViewTransition as 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}) { + // Анимация от запасного варианта Suspense до контента + return ( + + + + } + > + {/* Анимация контента вверх */} + + + + + ); +} +function VideoInfoFallback() { + return ( + <> +
+
+ + ); +} -/* Previously defined animations below */ +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); + return ( + { + navigateBack("/"); + }} + > + Назад +
+ } + > +
+ + + + +
+ + ); +} +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); + return ( + <> +

{details.title}

+

{details.description}

+ + ); +} +``` +```js src/Home.js +import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; +function SearchList({searchText, videos}) { + // Активация с помощью useDeferredValue ("когда") + const deferredSearchText = useDeferredValue(searchText); + const filteredVideos = filterVideos(videos, deferredSearchText); + return ( +
+
+ {filteredVideos.map((video) => ( + // Анимация каждого элемента в списке ("что") + + + ))} +
+ {filteredVideos.length === 0 && ( +
Нет результатов
+ )} +
+ ); +} +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); + + return ( + {count} Видео
}> + + + + ); +} -/* Slide animation for Suspense */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); } -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +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))); + }); } +``` -/* 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; +```js src/Icons.js hidden +export function ChevronLeft() { + 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 function PauseIcon() { + return ( + + + + ); } -::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; +export function PlayIcon() { + return ( + + + + ); } +export function Heart({liked, animate}) { + return ( + <> + + + -::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; + + {liked ? ( + + ) : ( + + )} + + + ); } -/* Keyframes to support our animations above. */ -@keyframes slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } +export function IconSearch(props) { + return ( + + + + ); } +``` -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } -} +```js src/Layout.js hidden +import {unstable_ViewTransition as ViewTransition} from 'react'; +import { useIsNavPending } from "./router"; -@keyframes fade-in { - from { - opacity: 0; - } +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Пользовательские классы в зависимости от типа перехода. */} + + {heading} + + {isPending && } +
+
+ {/* Отключение ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); } +``` -@keyframes fade-out { - to { - opacity: 0; - } -} +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. +const likedVideos = new Set(); -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } +export default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + return ( + + ); } +``` -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } -} +```js src/Videos.js hidden +import { useState, unstable_ViewTransition as ViewTransition } from "react"; +import LikeButton from "./LikeButton"; +import { useRouter } from "./router"; +import { PauseIcon, PlayIcon } from "./Icons"; +import { startTransition } from "react"; -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } +export function Thumbnail({ video, children }) { + // Добавьте имя для анимации с помощью перехода с общим элементом. + // Это использует анимацию по умолчанию, дополнительный CSS не требуется. + return ( + + + + ); } +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); } -::view-transition-new(.slow-fade) { - animation-duration: 500ms; +export function Video({ video }) { + const { navigate } = useRouter(); + + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + + +
+
{video.title}
+
{video.description}
+
+
+ +
+ ); } ``` -```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/data.js hidden +const videos = [ + { + id: '1', + title: 'Первое видео', + description: 'Описание видео', + image: 'blue', + }, + { + id: '2', + title: 'Второе видео', + description: 'Описание видео', + image: 'red', + }, + { + id: '3', + title: 'Третье видео', + description: 'Описание видео', + image: 'green', + }, + { + id: '4', + title: 'Четвертое видео', + description: 'Описание видео', + image: 'purple', + }, + { + id: '5', + title: 'Пятое видео', + description: 'Описание видео', + image: 'yellow', + }, + { + id: '6', + title: 'Шестое видео', + description: 'Описание видео', + image: 'gray', + }, +]; -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` +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; +} -```json package.json hidden -{ - "dependencies": { - "react": "experimental", - "react-dom": "experimental", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" +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; } ``` - - -### Final result {/*final-result*/} +```js src/router.js hidden +import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react"; -By adding a few `` components and a few lines of CSS, we were able to add all the animations above into the final result. +export function Router({ children }) { + const [isPending, startTransition] = useTransition(); + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + function navigate(url) { + startTransition(() => { + // Тип перехода для причины "переход вперед" + addTransitionType('nav-forward'); + go(url); + }); + } + function navigateBack(url) { + startTransition(() => { + // Тип перехода для причины "переход назад" + addTransitionType('nav-back'); + go(url); + }); + } -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. + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + + useEffect(() => { + function handlePopState() { + // Это не должно анимироваться, так как восстановление должно быть синхронным. + // Даже если это переход. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Ничего не делать. URL уже обновлен. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); -Let's remove the slow fade, and take a look at the final result: + return ( + + {children} + + ); +} - +const RouterContext = createContext({ url: "/", params: {} }); -```js src/App.js -import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; +export function useRouter() { + return use(RouterContext); +} -export default function App() { - const {url} = useRouter(); +export function useIsNavPending() { + return use(RouterContext).isPending; +} - // Animate with a cross fade between pages. - return ( - - {url === '/' ? :
} - - ); +```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; } -``` -```js src/Details.js -import { use, Suspense, unstable_ViewTransition as 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"; +@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; +} -function VideoDetails({id}) { - // Animate from Suspense fallback to content - return ( - - - - } - > - {/* Animate the content up */} - - - - - ); +@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; } -function VideoInfoFallback() { - return ( - <> -
-
- - ); +@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; } -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); - - return ( - { - navigateBack("/"); - }} - > - Back -
- } - > -
- - - - -
- - ); +* { + box-sizing: border-box; } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); +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%; } -``` -```js src/Home.js -import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; +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; +} -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
- )} -
- ); +#root { + flex: 1 1; + height: auto; + background-color: #fff; + border-radius: 10px; + max-width: 450px; + min-height: 600px; + padding-bottom: 10px; } -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(''); - - return ( - {count} Videos
}> - - - - ); +h1 { + margin-top: 0; + font-size: 22px; } -function SearchInput({ value, onChange }) { - const id = useId(); - return ( -
e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
-
- ); +h2 { + margin-top: 0; + font-size: 20px; } -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))); - }); +h3 { + margin-top: 0; + font-size: 18px; } -``` -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); +h4 { + margin-top: 0; + font-size: 16px; } -export function PauseIcon() { - return ( - - - - ); +h5 { + margin-top: 0; + font-size: 14px; } -export function PlayIcon() { - return ( - - - - ); +h6 { + margin-top: 0; + font-size: 12px; } -export function Heart({liked, animate}) { - return ( - <> - - - - - {liked ? ( - - ) : ( - - )} - - - ); +code { + font-size: 1.2em; } -export function IconSearch(props) { - return ( - - - - ); +ul { + padding-inline-start: 20px; } -``` -```js src/Layout.js -import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; +.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; +} -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}
-
-
-
- ); +.absolute { + position: absolute; } -``` -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; +.overflow-visible { + overflow: visible; +} -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); +.visible { + overflow: visible; +} -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); - return ( - - ); +.fit { + width: fit-content; } -``` -```js src/Videos.js -import { useState, unstable_ViewTransition as 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. - return ( - - - - ); +/* 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; +} -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); +.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; +} - return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - - ); +.content { + padding: 0 12px; + margin-top: 4px; } -export function Video({ video }) { - const { navigate } = useRouter(); - return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - +.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); +} -
-
{video.title}
-
{video.description}
-
-
- -
- ); +@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; } -``` - - -```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; +.like-button:focus { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); } -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; +.like-button:active { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); + transform: scaleX(0.95) scaleY(0.95); } -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; +.like-button:hover { + background-color: #f6f7f9; } -``` -```js src/router.js -import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react"; +.like-button.liked { + color: #a6423a; +} -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); - }); +/* Icons */ +@keyframes circle { + 0% { + transform: scale(0); + stroke-width: 16px; } - const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); + 50% { + transform: scale(.5); + stroke-width: 16px; } - - 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} - - ); + to { + transform: scale(1); + stroke-width: 0; + } } -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); +.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); } -export function useIsNavPending() { - return use(RouterContext).isPending; +.circle.liked.animate { + animation: circle .3s forwards; } -``` - -```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; +.heart { + width: 1.5rem; + height: 1.5rem; } -@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; +.heart.liked { + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4, 0, .2, 1); } -@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; +.heart.liked.animate { + animation: scale .35s ease-in-out forwards; } -@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; +.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)); } -* { - box-sizing: border-box; +.chevron-left { + margin-top: 2px; + rotate: 90deg; } -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; +/* 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; } -#root { - flex: 1 1; - height: auto; - background-color: #fff; - border-radius: 10px; - max-width: 450px; - min-height: 600px; - padding-bottom: 10px; +.thumbnail.blue { + background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); } -h1 { - margin-top: 0; - font-size: 22px; +.thumbnail.red { + background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); } -h2 { - margin-top: 0; - font-size: 20px; +.thumbnail.green { + background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); } -h3 { - margin-top: 0; - font-size: 18px; +.thumbnail.purple { + background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); } -h4 { - margin-top: 0; - font-size: 16px; +.thumbnail.yellow { + background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); } -h5 { - margin-top: 0; - font-size: 14px; +.thumbnail.gray { + background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); } -h6 { - margin-top: 0; - font-size: 12px; +.video { + display: flex; + flex-direction: row; + gap: 0.75rem; + align-items: center; } -code { - font-size: 1.2em; +.video .link { + display: flex; + flex-direction: row; + flex: 1 1 0; + gap: 0.125rem; + outline-offset: 4px; + cursor: pointer; } -ul { - padding-inline-start: 20px; +.video .info { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 8px; + gap: 0.125rem; } -.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; +.video .info:hover { + text-decoration: underline; } -.absolute { - position: absolute; +.video-title { + font-size: 15px; + line-height: 1.25; + font-weight: 700; + color: #23272f; } -.overflow-visible { - overflow: visible; +.video-description { + color: #5e687e; + font-size: 13px; } -.visible { - overflow: visible; +/* 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; } -.fit { - width: fit-content; +.video-details-title { + margin-top: 8px; } - -/* Layout */ -.page { +.video-details-speaker { display: flex; - flex-direction: column; - height: 100%; + gap: 8px; + margin-top: 10px } -.top-hero { - height: 200px; +.back { display: flex; - justify-content: center; align-items: center; - background-image: conic-gradient( - from 90deg at -10% 100%, - #2b303b 0deg, - #2b303b 90deg, - #16181d 1turn - ); + margin-left: -5px; + cursor: pointer; } -.bottom { - flex: 1; - overflow: auto; +.back:hover { + text-decoration: underline; } -.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; +.info-title { + font-size: 1.5rem; font-weight: 700; - font-size: 20px; - z-index: 100; - cursor: default; + line-height: 1.25; + margin: 8px 0 0 0 ; } -.content { - padding: 0 12px; - margin-top: 4px; +.info-description { + margin: 8px 0 0 0; } +.controls { + cursor: pointer; +} -.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); +.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; } -@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; + +.fallback.title { + width: 130px; + height: 30px; + +} + +.fallback.description { + width: 150px; + height: 21px; +} + +@keyframes shimmer { + 0% { + background-position: -468px 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; + + 100% { + background-position: 468px 0; } } -/* LikeButton */ -.like-button { - outline-offset: 2px; - position: relative; +.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; - justify-content: center; - width: 2.5rem; - height: 2.5rem; + 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-radius: 9999px; border: none; - outline: none 2px; - color: #5e687e; - background: none; + align-items: center; + color: rgb(35 39 47); + border-radius: 9999px; + vertical-align: middle; + font-size: 15px; } -.like-button:focus { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); +.search-input input:hover, .search-input input:active { + background-color: rgb(235 236 240/ 0.8); + color: rgb(35 39 47/ 0.8); } -.like-button:active { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); - transform: scaleX(0.95) scaleY(0.95); +/* Home */ +.video-list { + position: relative; } -.like-button:hover { - background-color: #f6f7f9; +.video-list .videos { + display: flex; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + height: 100%; } +``` -.like-button.liked { - color: #a6423a; -} -/* Icons */ -@keyframes circle { - 0% { - transform: scale(0); - stroke-width: 16px; - } +```css src/animations.css +/* No additional animations needed */ - 50% { - transform: scale(.5); - stroke-width: 16px; - } - to { - transform: scale(1); - stroke-width: 0; - } + + + + + + + +/* 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; } -.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); +::view-transition-new(.slide-up) { + animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; } -.circle.liked.animate { - animation: circle .3s forwards; +/* 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; } -.heart { - width: 1.5rem; - height: 1.5rem; +::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; } -.heart.liked { - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4, 0, .2, 1); +::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; } -.heart.liked.animate { - animation: scale .35s ease-in-out forwards; +::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; } -.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)); +/* Keyframes to support our animations above. */ +@keyframes slide-up { + from { + transform: translateY(10px); + } + to { + transform: translateY(0); + } } -.chevron-left { - margin-top: 2px; - rotate: 90deg; +@keyframes slide-down { + from { + transform: translateY(0); + } + to { + transform: translateY(10px); + } } +@keyframes fade-in { + from { + opacity: 0; + } +} -/* 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; +@keyframes fade-out { + to { + opacity: 0; + } } -.thumbnail.blue { - background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); +@keyframes slide-to-right { + to { + transform: translateX(50px); + } } -.thumbnail.red { - background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); +@keyframes slide-from-right { + from { + transform: translateX(50px); + } + to { + transform: translateX(0); + } } -.thumbnail.green { - background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); +@keyframes slide-to-left { + to { + transform: translateX(-50px); + } } -.thumbnail.purple { - background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); +@keyframes slide-from-left { + from { + transform: translateX(-50px); + } + to { + transform: translateX(0); + } } -.thumbnail.yellow { - background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); + +/* 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": "experimental", + "react-dom": "experimental", + "react-scripts": "latest" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } } +``` + +## Конечный результат {/*final-result*/} + +Добавив несколько компонентов `` и несколько строк CSS, мы смогли добавить все анимации, показанные выше, в конечный результат. + +Мы с энтузиазмом относимся к View Transitions и считаем, что они выведут на новый уровень приложения, которые вы сможете создавать. Они готовы к использованию уже сегодня в экспериментальном канале релизов React. -.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; -} +```js src/App.js +import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; -.video .info { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 8px; - gap: 0.125rem; -} +export default function App() { + const {url} = useRouter(); -.video .info:hover { - text-decoration: underline; + // Анимируем страницы с помощью перекрестного затухания. + return ( + + {url === '/' ? :
} + + ); } +``` -.video-title { - font-size: 15px; - line-height: 1.25; - font-weight: 700; - color: #23272f; -} +```js src/Details.js +import { use, Suspense, unstable_ViewTransition as 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"; -.video-description { - color: #5e687e; - font-size: 13px; +function VideoDetails({id}) { + // Анимируем от запасного варианта Suspense до контента + return ( + + + + } + > + {/* Анимируем контент вверх */} + + + + + ); } -/* 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; +function VideoInfoFallback() { + return ( + <> +
+
+ + ); } -.video-details-title { - margin-top: 8px; -} +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px + return ( + { + navigateBack("/"); + }} + > + Назад +
+ } + > +
+ + + + +
+ + ); } -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); + return ( + <> +

{details.title}

+

{details.description}

+ + ); } +``` -.back:hover { - text-decoration: underline; +```js src/Home.js +import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; + +function SearchList({searchText, videos}) { + // Активируем с помощью useDeferredValue ("когда") + const deferredSearchText = useDeferredValue(searchText); + const filteredVideos = filterVideos(videos, deferredSearchText); + return ( +
+
+ {filteredVideos.map((video) => ( + // Анимируем каждый элемент в списке ("что") + + + ))} +
+ {filteredVideos.length === 0 && ( +
Нет результатов
+ )} +
+ ); } -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); + + return ( + {count} Видео
}> + + +
+ ); } -.info-description { - margin: 8px 0 0 0; +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); } -.controls { - cursor: pointer; +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))); + }); } +``` -.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; +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); +} - 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; +export function PauseIcon() { + return ( + + + + ); } +export function PlayIcon() { + return ( + + + + ); +} +export function Heart({liked, animate}) { + return ( + <> + + + -.fallback.title { - width: 130px; - height: 30px; - + + {liked ? ( + + ) : ( + + )} + + + ); } -.fallback.description { - width: 150px; - height: 21px; +export function IconSearch(props) { + return ( + + + + ); } +``` -@keyframes shimmer { - 0% { - background-position: -468px 0; - } +```js src/Layout.js +import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; - 100% { - background-position: 468px 0; - } +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Пользовательские классы в зависимости от типа перехода. */} + + {heading} + + {isPending && } +
+
+ {/* Отключение ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); } +``` -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; -.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; -} +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. +const likedVideos = new Set(); -.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 default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + 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/Videos.js +import { useState, unstable_ViewTransition as ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react"; -/* Home */ -.video-list { - position: relative; +export function Thumbnail({ video, children }) { + // Добавляем имя для анимации с помощью перехода с общим элементом. + return ( + + + + ); } -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` -```css src/animations.css -/* Slide animations for Suspense the fallback down */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; -} +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); } -/* 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; -} +export function Video({ video }) { + const { navigate } = useRouter(); -::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; -} + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + -::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; +
+
{video.title}
+
{video.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; -} -/* Keyframes to support our animations above. */ -@keyframes slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } -} +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'Первое видео', + description: 'Описание видео', + image: 'blue', + }, + { + id: '2', + title: 'Второе видео', + description: 'Описание видео', + image: 'red', + }, + { + id: '3', + title: 'Третье видео', + description: 'Описание видео', + image: 'green', + }, + { + id: '4', + title: 'Четвертое видео', + description: 'Описание видео', + image: 'purple', + }, + { + id: '5', + title: 'Пятое видео', + description: 'Описание видео', + image: 'yellow', + }, + { + id: '6', + title: 'Шестое видео', + description: 'Описание видео', + image: 'gray', + }, +]; -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } +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 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; } +``` -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} +```js src/router.js +import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react"; -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } -} +export function Router({ children }) { + const [isPending, startTransition] = useTransition(); + function navigate(url) { + startTransition(() => { + // Тип перехода для причины "nav forward" + addTransitionType('nav-forward'); + go(url); + }); + } + function navigateBack(url) { + startTransition(() => { + // Тип перехода для причины "nav backward" + addTransitionType('nav-back'); + go(url); + }); + } -@keyframes slide-to-left { - to { - transform: translateX(-50px); + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + + useEffect(() => { + function handlePopState() { + // Это не должно анимироваться, так как восстановление должно быть синхронным. + // Даже если это переход. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Нет действия. URL уже обновлен. + }, + }); + }); } -} + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } + return ( + + {children} + + ); } -``` - -```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 RouterContext = createContext({ url: "/", params: {} }); -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` +export function useRouter() { + return use(RouterContext); +} -```json package.json hidden -{ - "dependencies": { - "react": "experimental", - "react-dom": "experimental", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } +export function useIsNavPending() { + return use(RouterContext).isPending; } -``` -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. +Если вам интересно узнать больше о том, как они работают, ознакомьтесь с разделом [Как работает ``](/reference/react/ViewTransition#how-does-viewtransition-work) в документации. -_For more background on how we built View Transitions, see: [#31975](https://github.com/facebook/react/pull/31975), [#32105](https://github.com/facebook/react/pull/32105), [#32041](https://github.com/facebook/react/pull/32041), [#32734](https://github.com/facebook/react/pull/32734), [#32797](https://github.com/facebook/react/pull/32797) [#31999](https://github.com/facebook/react/pull/31999), [#32031](https://github.com/facebook/react/pull/32031), [#32050](https://github.com/facebook/react/pull/32050), [#32820](https://github.com/facebook/react/pull/32820), [#32029](https://github.com/facebook/react/pull/32029), [#32028](https://github.com/facebook/react/pull/32028), and [#32038](https://github.com/facebook/react/pull/32038) by [@sebmarkbage](https://twitter.com/sebmarkbage) (thanks Seb!)._ +_Для получения дополнительной информации о том, как мы реализовали View Transitions, см.: [#31975](https://github.com/facebook/react/pull/31975), [#32105](https://github.com/facebook/react/pull/32105), [#32041](https://github.com/facebook/react/pull/32041), [#32734](https://github.com/facebook/react/pull/32734), [#32797](https://github.com/facebook/react/pull/32797) [#31999](https://github.com/facebook/react/pull/31999), [#32031](https://github.com/facebook/react/pull/32031), [#32050](https://github.com/facebook/react/pull/32050), [#32820](https://github.com/facebook/react/pull/32820), [#32029](https://github.com/facebook/react/pull/32029), [#32028](https://github.com/facebook/react/pull/32028) и [#32038](https://github.com/facebook/react/pull/32038) от [@sebmarkbage](https://twitter.com/sebmarkbage) (спасибо, Себ!)._ --- -## Activity {/*activity*/} +## Активность {/*activity*/} -In [past](/blog/2022/06/15/react-labs-what-we-have-been-working-on-june-2022#offscreen) [updates](/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024#offscreen-renamed-to-activity), we shared that we were researching an API to allow components to be visually hidden and deprioritized, preserving UI state with reduced performance costs relative to unmounting or hiding with CSS. +В [предыдущих](/blog/2022/06/15/react-labs-what-we-have-been-working-on-june-2022#offscreen) [обновлениях](/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024#offscreen-renamed-to-activity) мы рассказывали об исследовании API, который позволил бы визуально скрывать компоненты и снижать их приоритет, сохраняя состояние пользовательского интерфейса с меньшими затратами производительности по сравнению с размонтированием или скрытием с помощью CSS. -We're now ready to share the API and how it works, so you can start testing it in experimental React versions. +Теперь мы готовы представить API и объяснить, как он работает, чтобы вы могли начать тестировать его в экспериментальных версиях React. -`` is a new component to hide and show parts of the UI: +`` — это новый компонент для скрытия и отображения частей пользовательского интерфейса: ```js [[1, 1, "'visible'"], [2, 1, "'hidden'"]] @@ -11477,25 +8651,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. +Когда `` видим, он рендерится как обычно. Когда `` скрыт, он размонтируется, но сохранит своё состояние и продолжит рендериться с более низким приоритетом, чем всё видимое на экране. -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. +Вы можете использовать `Activity` для сохранения состояния частей пользовательского интерфейса, которые пользователь не использует в данный момент, или для предварительного рендеринга частей, которые пользователь, вероятно, будет использовать следующими. -Let's look at some examples improving the View Transition examples above. +Рассмотрим несколько примеров, улучшающих примеры View Transition выше. -**Effects don’t mount when an Activity is hidden.** +**Эффекты не монтируются, когда `` скрыт.** -When an `` is `hidden`, Effects are unmounted. Conceptually, the component is unmounted, but React saves the state for later. +Когда `` имеет режим `hidden`, эффекты размонтируются. Концептуально компонент размонтируется, но React сохраняет его состояние для последующего использования. -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. +На практике это работает ожидаемо, если вы следовали руководству [Вам может не понадобиться эффект](/learn/you-might-not-need-an-effect). Чтобы быстро находить проблемные эффекты, мы рекомендуем добавить [``](/reference/react/StrictMode), который будет принудительно выполнять размонтирование и монтирование ``, чтобы выявить любые неожиданные побочные эффекты. -### Restoring state with Activity {/*restoring-state-with-activity*/} +### Восстановление состояния с помощью Activity {/*restoring-state-with-activity*/} -When a user navigates away from a page, it's common to stop rendering the old page: +Когда пользователь уходит со страницы, часто прекращают рендеринг старой страницы: ```js {6,7} function App() { @@ -11510,9 +8684,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 `` field, when the user leaves the page the `` is unmounted, and all of the text they had typed is lost. +Однако это означает, что если пользователь вернётся на старую страницу, всё предыдущее состояние будет потеряно. Например, если на странице `` есть поле ``, когда пользователь покидает страницу, `` размонтируется, и весь введённый им текст будет потерян. -Activity allows you to keep the state around as the user changes pages, so when they come back they can resume where they left off. This is done by wrapping part of the tree in `` and toggling the `mode`: +Activity позволяет сохранять состояние при смене страниц, чтобы пользователь мог продолжить с того места, где остановился. Это достигается путём оборачивания части дерева в `` и переключения `mode`: ```js {6-8} function App() { @@ -11529,9 +8703,9 @@ function App() { } ``` -With this change, we can improve on our View Transitions example above. Before, when you searched for a video, selected one, and returned, your search filter was lost. With Activity, your search filter is restored and you can pick up where you left off. +С этим изменением мы можем улучшить наш пример View Transitions выше. Раньше, когда вы искали видео, выбирали его и возвращались, ваш фильтр поиска терялся. С Activity ваш фильтр поиска восстанавливается, и вы можете продолжить с того места, где остановились. -Try searching for a video, selecting it, and clicking "back": +Попробуйте поискать видео, выбрать его и нажать "назад": @@ -11542,9 +8716,9 @@ export default function App() { const { url } = useRouter(); return ( - // View Transitions know about Activity + // View Transitions знают об Activity - {/* Render Home in Activity so we don't lose state */} + {/* Рендерим Home в Activity, чтобы не потерять состояние */} @@ -11563,17 +8737,17 @@ import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; function VideoDetails({id}) { - // Animate from Suspense fallback to content + // Анимация из запасного варианта Suspense в контент return ( } > - {/* Animate the content up */} + {/* Анимация контента вверх */} @@ -11604,7 +8778,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад } > @@ -11633,17 +8807,17 @@ function VideoInfo({ id }) { import { useId, useState, use, useDeferredValue, unstable_ViewTransition as 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") + // Активация с помощью useDeferredValue ("когда") const deferredSearchText = useDeferredValue(searchText); const filteredVideos = filterVideos(videos, deferredSearchText); return (
{filteredVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{filteredVideos.map((video) => ( - // Animate each item in list ("what") + // Анимация каждого элемента списка ("что") @@ -11659,7 +8833,7 @@ export default function Home() { const [searchText, setSearchText] = useState(''); return ( - {count} Videos
}> + {count} Видео
}> @@ -11671,7 +8845,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -11680,7 +8854,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -11833,7 +9007,7 @@ export default function Page({ heading, children }) {
- {/* Custom classes based on transition type. */} + {/* Пользовательские классы в зависимости от типа перехода. */} }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Отказ от ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -11861,8 +9035,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. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -11871,7 +9045,7 @@ export default function LikeButton({video}) { return (