From 92ebaf8f7ac44f48a30d60f6866cf1e50a6a2553 Mon Sep 17 00:00:00 2001 From: "translate-react-bot[bot]" <251169733+translate-react-bot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:02:02 +0000 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20translate=20`react-labs-view-transi?= =?UTF-8?q?tions-activity-and-more.md`=20to=20=D0=A0=D1=83=D1=81=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...labs-view-transitions-activity-and-more.md | 8047 +++++++++++++++-- 1 file changed, 7077 insertions(+), 970 deletions(-) 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..c4c7776e67 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,79 +1,79 @@ --- -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) +- [Переходы](#view-transitions) +- [Активность](#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) -- [Fragment Refs](#fragment-refs) -- [Concurrent Stores](#concurrent-stores) +Мы также делимся обновлениями о новых функциях, находящихся в разработке: +- [Треки производительности React](#react-performance-tracks) +- [Расширение IDE для компилятора](#compiler-ide-extension) +- [Автоматические зависимости эффектов](#automatic-effect-dependencies) +- [Рефы фрагментов](#fragment-refs) +- [Конкурентные хранилища](#concurrent-stores) --- -# 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 View Transitions — это новая экспериментальная функция, которая упрощает добавление анимаций при переходе между состояниями пользовательского интерфейса в вашем приложении. По сути, эти анимации используют новый API [`startViewTransition`](https://developer.mozilla.org/en-US/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: +Вы можете определить, "когда" анимировать, используя один из следующих трех триггеров для View Transition: ```js -// "when" to animate. +// "Когда" анимировать. // Transitions startTransition(() => setState(...)); @@ -83,14 +83,14 @@ 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-анимации по умолчанию для View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations) (обычно плавное перекрестное затухание). Вы можете использовать [псевдоселекторы view transition](https://developer.mozilla.org/en-US/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 +99,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 браузера View Transition и хотите узнать, как 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. +В этой статье мы рассмотрим несколько примеров использования 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 +118,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 +164,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад } > @@ -194,7 +194,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -203,7 +203,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -234,11 +234,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) => ( @@ -250,8 +250,6 @@ export default function Home() { ); } -``` - ```js src/Icons.js export function ChevronLeft() { return ( @@ -569,7 +567,6 @@ export function fetchVideoDetails(id) { return promise; } ``` - ```js src/router.js import { useState, @@ -606,14 +603,14 @@ export function Router({ children }) { }); } function navigate(url) { - // Update router state in transition. + // Обновляем состояние роутера в переходе. startTransition(() => { go(url); }); } function navigateBack(url) { - // Update router state in transition. + // Обновляем состояние роутера в переходе. startTransition(() => { go(url); }); @@ -621,13 +618,13 @@ export function Router({ children }) { 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. + // Ничего не делаем. URL уже обновлен. }, }); }); @@ -657,7 +654,6 @@ export function Router({ children }) { ); } ``` - ```css src/styles.css @font-face { font-family: Optimistic Text; @@ -1176,6 +1172,7 @@ ul { width: 100%; position: relative; } +``` .search-icon { position: absolute; @@ -2334,7 +2331,8 @@ ul { border-radius: 5px; overflow: hidden; - animation: 1s linear 1s infinite shimmer; + ```css +animation: 1s linear 1s infinite shimmer; animation-delay: 300ms; animation-duration: 1s; animation-fill-mode: forwards; @@ -2458,72 +2456,54 @@ root.render( -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: +#### View Transitions не заменяют анимации, управляемые CSS и JavaScript {/*view-transitions-do-not-replace-css-and-js-driven-animations*/} -```js -// Layout.js - - {children} - -``` +View Transitions предназначены для UI-переходов, таких как навигация, раскрытие, открытие или изменение порядка элементов. Они не предназначены для замены всех анимаций в вашем приложении. -In practice, navigations should be done via "enter" and "exit" props, or by using Transition Types. +В нашем примере приложения выше обратите внимание, что уже есть анимации при нажатии кнопки «лайк» и в мерцании запасного варианта Suspense. Это хорошие варианты использования для CSS-анимаций, поскольку они анимируют конкретный элемент. -### Customizing animations {/*customizing-animations*/} +### Анимация переходов {/*animating-navigations*/} -By default, `` includes the default cross-fade from the browser. +Наше приложение включает роутер с поддержкой Suspense, где [переходы между страницами уже помечены как Transitions](/reference/react/useTransition#building-a-suspense-enabled-router), что означает, что переходы выполняются с помощью `startTransition`: -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). +```js +function navigate(url) { + startTransition(() => { + go(url); + }); +} +``` -For example, we can slow down the `default` cross fade animation: +`startTransition` является триггером для View Transition, поэтому мы можем добавить `` для анимации между страницами: ```js - - +// "Что" анимировать + + {url === '/' ? : } ``` -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; -} +Когда `url` изменяется, рендерятся `` и новый маршрут. Поскольку `` был обновлен внутри `startTransition`, он активируется для анимации. -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` -Now, the cross fade is slower: +По умолчанию View Transitions включают стандартную анимацию браузера cross-fade (перекрестное затухание). Добавив это в наш пример, мы получим перекрестное затухание при каждом переходе между страницами: ```js src/App.js active -import { unstable_ViewTransition as ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; +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. + const {url} = useRouter(); + + // Используйте ViewTransition для анимации между страницами. + // Дополнительный CSS по умолчанию не требуется. return ( - + {url === '/' ? :
} ); @@ -2752,7 +2732,7 @@ export function Heart({liked, animate}) { )} @@ -2777,12 +2757,37 @@ export function IconSearch(props) { } ``` -```js src/Layout.js hidden +```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/Layout.js +import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return (
@@ -2981,32 +2986,27 @@ export function fetchVideoDetails(id) { } ``` -```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; -} +```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, }); - const [isPending, startTransition] = useTransition(); + function go(url) { setRouterState({ @@ -3016,15 +3016,9 @@ export function Router({ children }) { }, }); } - function navigate(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } + function navigateBack(url) { - // Update router state in transition. startTransition(() => { go(url); }); @@ -3067,7 +3061,16 @@ export function Router({ 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 { @@ -3587,6 +3590,7 @@ ul { width: 100%; position: relative; } +``` .search-icon { position: absolute; @@ -4743,7 +4747,8 @@ ul { border-radius: 5px; overflow: hidden; - animation: 1s linear 1s infinite shimmer; + ```css +animation: 1s linear 1s infinite shimmer; animation-delay: 300ms; animation-duration: 1s; animation-fill-mode: forwards; @@ -4831,38 +4836,10 @@ ul { } ``` - -```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'; @@ -4895,73 +4872,60 @@ root.render( -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. +Поскольку наш роутер уже обновляет маршрут с помощью `startTransition`, это изменение одной строки для добавления `` активирует анимацию перехода с плавным затуханием по умолчанию. -For more info, see the docs for [Animating a Shared Element](/reference/react/ViewTransition#animating-a-shared-element). +Если вам интересно, как это работает, ознакомьтесь с документацией [Как работает ``?](/reference/react/ViewTransition#how-does-viewtransition-work) -### 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: +#### Отказ от анимаций `` {/*opting-out-of-viewtransition-animations*/} -```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": +Чтобы исправить это, мы оборачиваем дочерние элементы маршрутов значением `"none"`, чтобы каждая страница могла контролировать свою собственную анимацию: -```js {4,5} - - {heading} +```js +// Layout.js + + {children} ``` -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: +На практике навигация должна осуществляться с помощью пропсов "enter" и "exit" или с использованием типов переходов (Transition Types). -```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: ... -} +### Настройка анимаций {/*customizing-animations*/} -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: ... +По умолчанию `` включает стандартное перекрестное затухание браузера. + +Чтобы настроить анимации, вы можете передать пропсы компоненту ``, чтобы указать, какие анимации использовать, в зависимости от [того, как активируется ``](/reference/react/ViewTransition#props). + +Например, мы можем замедлить стандартное перекрестное затухание: + +```js + + + +``` + +И определить `slow-fade` в CSS с помощью [классов view transition](/reference/react/ViewTransition#view-transition-classes): + +```css +::view-transition-old(.slow-fade) { + animation-duration: 500ms; } -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: ... +::view-transition-new(.slow-fade) { + animation-duration: 500ms; } ``` -Now we can animate the header along with thumbnail based on navigation type: +Теперь перекрестное затухание будет медленнее: -```js src/App.js hidden +```js src/App.js active import { unstable_ViewTransition as ViewTransition } from "react"; import Details from "./Details"; import Home from "./Home"; @@ -4970,10 +4934,11 @@ import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); - // Keeping our default slow-fade. + // Define a default animation of .slow-fade. + // See animations.css for the animation definiton. return ( - {url === "/" ? :
} + {url === '/' ? :
} ); } @@ -5201,7 +5166,7 @@ export function Heart({liked, animate}) { )} @@ -5226,24 +5191,17 @@ export function IconSearch(props) { } ``` -```js src/Layout.js active +```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 (
- {/* Custom classes based on transition type. */} - - {heading} - + {heading} {isPending && }
@@ -5257,11 +5215,36 @@ export default function Page({ heading, children }) {
); } -``` -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; +```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. @@ -5291,28 +5274,12 @@ export default function LikeButton({video}) { ``` ```js src/Videos.js hidden -import { useState, unstable_ViewTransition as ViewTransition } from "react"; +import { useState } 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); @@ -5330,6 +5297,18 @@ export function VideoControls() { ); } +export function Thumbnail({ video, children }) { + return ( + + ); +} + export function Video({ video }) { const { navigate } = useRouter(); @@ -5441,39 +5420,55 @@ export function fetchVideoDetails(id) { } ``` -```js src/router.js -import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react"; +```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(() => { - // Transition type for the cause "nav forward" - addTransitionType('nav-forward'); go(url); }); } + function navigateBack(url) { + // Update router state in transition. startTransition(() => { - // Transition type for the cause "nav backward" - addTransitionType('nav-back'); go(url); }); } - - const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - useEffect(() => { function handlePopState() { // This should not animate because restoration has to be synchronous. @@ -5511,17 +5506,6 @@ export function Router({ children }) { ); } - -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); -} - -export function useIsNavPending() { - return use(RouterContext).isPending; -} - ``` ```css src/styles.css hidden @@ -6042,6 +6026,7 @@ ul { width: 100%; position: relative; } +``` .search-icon { position: absolute; @@ -7273,7 +7258,8 @@ ul { border-radius: 5px; overflow: hidden; - animation: 1s linear 1s infinite shimmer; + ```css +animation: 1s linear 1s infinite shimmer; animation-delay: 300ms; animation-duration: 1s; animation-fill-mode: forwards; @@ -7361,108 +7347,8 @@ ul { } ``` - ```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. */ +/* Определяем .slow-fade с помощью классов view transition */ ::view-transition-old(.slow-fade) { animation-duration: 500ms; } @@ -7509,41 +7395,25 @@ root.render( -We can also provide custom animations using an `exit` on the fallback, and `enter` on the content: +См. [Стилизация View Transitions](/reference/react/ViewTransition#styling-view-transitions) для полного руководства по стилизации ``. -```js {3,8} - - - - } -> - - - - -``` +### Переходы между общими элементами {/*shared-element-transitions*/} -Here's how we'll define `slide-down` and `slide-up` with CSS: +Когда две страницы содержат один и тот же элемент, часто хочется анимировать его переход с одной страницы на другую. -```css {1, 6} -::view-transition-old(.slide-down) { - /* Slide the fallback down */ - animation: ...; -} +Для этого вы можете добавить уникальное `name` к ``: -::view-transition-new(.slide-up) { - /* Slide the content up */ - animation: ...; -} +```js + + + ``` -Now, the Suspense content replaces the fallback with a sliding animation: +Теперь миниатюра видео будет анимироваться между двумя страницами: -```js src/App.js hidden +```js src/App.js import { unstable_ViewTransition as ViewTransition } from "react"; import Details from "./Details"; import Home from "./Home"; @@ -7552,7 +7422,9 @@ import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); - // Default slow-fade animation. + // Сохраняем наш стандартный медленный переход. + // Это позволяет контенту, не участвующему в переходе + // между общими элементами, плавно появляться и исчезать. return ( {url === "/" ? :
} @@ -7561,24 +7433,21 @@ 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 }) { +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); return ( - - - - } - > - {/* Animate the content up */} - - - - + <> +

{details.title}

+

{details.description}

+ ); } @@ -7605,7 +7474,7 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад
} > @@ -7613,21 +7482,14 @@ export default function Details() { - + }> + +
); } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} ``` ```js src/Home.js hidden @@ -7642,7 +7504,7 @@ function SearchInput({ value, onChange }) { return ( e.preventDefault()}>
@@ -7651,7 +7513,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -7682,11 +7544,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) => ( @@ -7819,29 +7681,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}
@@ -7856,8 +7710,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 +7720,7 @@ export default function LikeButton({video}) { return ( ); } -``` -```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"; +```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. + // Добавляем имя для анимации с помощью общей переходной анимации элементов. + // Используется анимация по умолчанию, дополнительный CSS не требуется. return ( ); } -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}) { - // Activate with useDeferredValue ("when") - const deferredSearchText = useDeferredValue(searchText); - const filteredVideos = filterVideos(videos, deferredSearchText); - return ( -
-
- {filteredVideos.map((video) => ( - // Animate each item in list ("what") - - - ))} -
- {filteredVideos.length === 0 && ( -
No results
- )} -
- ); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(''); - - return ( - {count} Videos
}> - - - - ); -} +```js src/Home.js hidden +import { Video } from "./Videos"; +import Layout from "./Layout"; +import { fetchVideos } from "./data"; +import { useId, useState, use } from "react"; +import { IconSearch } from "./Icons"; function SearchInput({ value, onChange }) { const id = useId(); @@ -10341,6 +10119,29 @@ function filterVideos(videos, query) { return keywords.every((kw) => words.some((w) => w.includes(kw))); }); } + +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(""); + const foundVideos = filterVideos(videos, searchText); + return ( + {count} Videos
}> + +
+ {foundVideos.length === 0 && ( +
No results
+ )} +
+ {foundVideos.map((video) => ( +
+
+ + ); +} + ``` ```js src/Icons.js hidden @@ -10459,9 +10260,8 @@ export function IconSearch(props) { ); } -``` -```js src/Layout.js +```js src/Layout.js active import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; export default function Page({ heading, children }) { @@ -10470,7 +10270,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}
@@ -10498,8 +10298,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}) { @@ -10525,11 +10325,16 @@ export default function LikeButton({video}) { } ``` -```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"; +```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"; export function Thumbnail({ video, children }) { - // Add a name to animate with a shared element transition. + // Добавьте имя для анимации с помощью перехода с общим элементом. + // Это использует анимацию по умолчанию, дополнительный CSS не требуется. return (
{ - // Transition type for the cause "nav forward" + // Тип перехода для причины "nav forward" addTransitionType('nav-forward'); go(url); }); } function navigateBack(url) { startTransition(() => { - // Transition type for the cause "nav backward" + // Тип перехода для причины "nav backward" addTransitionType('nav-back'); go(url); }); } + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); - + function go(url) { setRouterState({ url, @@ -10706,13 +10511,13 @@ export function Router({ children }) { 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. + // Ничего не делать. URL уже обновлен. }, }); }); @@ -10751,7 +10556,6 @@ export function useRouter() { export function useIsNavPending() { return use(RouterContext).isPending; } - ``` ```css src/styles.css hidden @@ -11272,6 +11076,7 @@ ul { width: 100%; position: relative; } +``` .search-icon { position: absolute; @@ -12599,7 +12404,8 @@ ul { border-radius: 5px; overflow: hidden; - animation: 1s linear 1s infinite shimmer; + ```css +animation: 1s linear 1s infinite shimmer; animation-delay: 300ms; animation-duration: 1s; animation-fill-mode: forwards; @@ -12687,78 +12493,33 @@ ul { } ``` - ```css src/animations.css -/* No additional animations needed */ - - - - - - - - - -/* Previously defined animations below */ - - - - - - -/* 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; -} - -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; -} - -/* Animations for view transition classed added by transition type */ +/* Анимации для переходов между страницами, добавленные по типу перехода */ ::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ + /* при скольжении вперед "старая" страница должна уходить влево. */ animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; } ::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 slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } -} - -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } -} - +/* Новые ключевые кадры для поддержки наших анимаций выше. */ @keyframes fade-in { from { opacity: 0; @@ -12801,7 +12562,9 @@ ul { } } -/* Default .slow-fade. */ +/* Ранее определенные анимации. */ + +/* Стандартное .slow-fade. */ ::view-transition-old(.slow-fade) { animation-duration: 500ms; } @@ -12848,90 +12611,69 @@ root.render( -### Pre-rendering with Activity {/*prerender-with-activity*/} +### Анимация границ Suspense {/*animating-suspense-boundaries*/} -Sometimes, you may want to prepare the next part of the UI a user is likely to use ahead of time, so it's ready by the time they are ready to use it. This is especially useful if the next route needs to suspend on data it needs to render, because you can help ensure the data is already fetched before the user navigates. +Suspense также активирует View Transitions. -For example, our app currently needs to suspend to load the data for each video when you select one. We can improve this by rendering all of the pages in a hidden `` until the user navigates: +Чтобы анимировать переход от запасного варианта к контенту, мы можем обернуть `Suspense` в ``: -```js {2,5,8} - - - - - -
- - -
- +```js + }> + + + ``` -With this update, if the content on the next page has time to pre-render, it will animate in without the Suspense fallback. Click a video, and notice that the video title and description on the Details page render immediately, without a fallback: +Добавив это, запасной вариант будет плавно переходить в контент. Нажмите на видео, и вы увидите, как информация о видео анимируется: -```js src/App.js -import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, use } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; import {fetchVideos} from './data' - -export default function App() { +```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(); - const videoId = url.split("/").pop(); - const videos = use(fetchVideos()); - + + // Default slow-fade animation. return ( - - {/* Render videos in Activity to pre-render them */} - {videos.map(({id}) => ( - -
- - ))} - - - + + {url === "/" ? :
} ); } ``` -```js src/Details.js +```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"; -function VideoDetails({id}) { - // Animate from Suspense fallback to content. - // If this is pre-rendered then the fallback - // won't need to show. +function VideoDetails({ id }) { + // Cross-fade the fallback to content. return ( - - - - } - > - {/* Animate the content up */} - - - - + + }> + + + ); } function VideoInfoFallback() { return ( - <> -
-
- +
+
+
+
); } -export default function Details({id}) { +export default function Details() { const { url, navigateBack } = useRouter(); - const video = use(fetchVideo(id)); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); return ( -

{details.title}

-

{details.description}

- +
+

{details.title}

+

{details.description}

+
); } ``` ```js src/Home.js hidden -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") - const deferredSearchText = useDeferredValue(searchText); - const filteredVideos = filterVideos(videos, deferredSearchText); - return ( -
- {filteredVideos.length === 0 && ( -
No results
- )} -
- {filteredVideos.map((video) => ( - // Animate each item in list ("what") - - - ))} -
-
- ); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(''); - - return ( - {count} Videos
}> - - - - ); -} +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(); @@ -13042,6 +12754,29 @@ function filterVideos(videos, query) { return keywords.every((kw) => words.some((w) => w.includes(kw))); }); } + +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(""); + const foundVideos = filterVideos(videos, searchText); + return ( + {count} Videos
}> + +
+ {foundVideos.length === 0 && ( +
No results
+ )} +
+ {foundVideos.map((video) => ( +
+
+ + ); +} + ``` ```js src/Icons.js hidden @@ -13137,7 +12872,7 @@ export function Heart({liked, animate}) { )} @@ -13163,7 +12898,8 @@ 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(); @@ -13199,8 +12935,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}) { @@ -13234,8 +12970,8 @@ 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. + // Добавляем имя для анимации с помощью общей переходной анимации элементов. + // Используется анимация по умолчанию, дополнительный CSS не требуется. return ( + } + > +
+ + + + +
+ + ); +} + +function VideoInfo({ id }) { + const details = use(fetchVideoDetails(id)); + return ( + <> +

{details.title}

+

{details.description}

+ + ); +} +``` + +```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 ( +
+
+
+ {/* Custom classes based on transition type. */} + + {heading} + + {isPending && } +
+
+ {/* Opt-out of ViewTransition for the content. */} + {/* Content can define it's own ViewTransition. */} + +
+
{children}
+
+
+
+ ); +} +``` + +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; + +// A hack since we don't actually have a backend. +// Unlike local state, this survives videos being filtered. +const likedVideos = new Set(); + +export default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + return ( + + ); +} +``` + +```js src/Videos.js hidden +import { useState, 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, 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(() => { + // 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); + }); + } + + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + + useEffect(() => { + function handlePopState() { + // This should not animate because restoration has to be synchronous. + // Even though it's a transition. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Noop. URL has already updated. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); + + return ( + + {children} + + ); +} + +const RouterContext = createContext({ url: "/", params: {} }); + +export function useRouter() { + return use(RouterContext); +} + +export function useIsNavPending() { + return use(RouterContext).isPending; +} +``` + +```css src/styles.css hidden +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +* { + box-sizing: border-box; +} + +html { + background-image: url(https://react.dev/images/meta-gradient-dark.png); + background-size: 100%; + background-position: -100%; + background-color: rgb(64 71 86); + background-repeat: no-repeat; + height: 100%; + width: 100%; +} + +body { + font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + padding: 10px 0 10px 0; + margin: 0; + display: flex; + justify-content: center; +} + +#root { + flex: 1 1; + height: auto; + background-color: #fff; + border-radius: 10px; + max-width: 450px; + min-height: 600px; + padding-bottom: 10px; +} + +h1 { + margin-top: 0; + font-size: 22px; +} + +h2 { + margin-top: 0; + font-size: 20px; +} + +h3 { + margin-top: 0; + font-size: 18px; +} + +h4 { + margin-top: 0; + font-size: 16px; +} + +h5 { + margin-top: 0; + font-size: 14px; +} + +h6 { + margin-top: 0; + font-size: 12px; +} + +code { + font-size: 1.2em; +} + +ul { + padding-inline-start: 20px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.absolute { + position: absolute; +} + +.overflow-visible { + overflow: visible; +} + +.visible { + overflow: visible; +} + +.fit { + width: fit-content; +} + + +/* Layout */ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.top-hero { + height: 200px; + display: flex; + justify-content: center; + align-items: center; + background-image: conic-gradient( + from 90deg at -10% 100%, + #2b303b 0deg, + #2b303b 90deg, + #16181d 1turn + ); +} + +.bottom { + flex: 1; + overflow: auto; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0; + padding: 0 12px; + top: 0; + width: 100%; + height: 44px; + color: #23272f; + font-weight: 700; + font-size: 20px; + z-index: 100; + cursor: default; +} + +.content { + padding: 0 12px; + margin-top: 4px; +} + + +.loader { + color: #23272f; + font-size: 3px; + width: 1em; + margin-right: 18px; + height: 1em; + border-radius: 50%; + position: relative; + text-indent: -9999em; + animation: loading-spinner 1.3s infinite linear; + animation-delay: 200ms; + transform: translateZ(0); +} + +@keyframes loading-spinner { + 0%, + 100% { + box-shadow: 0 -3em 0 0.2em, + 2em -2em 0 0em, 3em 0 0 -1em, + 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 0; + } + 12.5% { + box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, + 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 -1em; + } + 25% { + box-shadow: 0 -3em 0 -0.5em, + 2em -2em 0 0, 3em 0 0 0.2em, + 2em 2em 0 0, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 -1em; + } + 37.5% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, + -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; + } + 50% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, + -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; + } + 62.5% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, + -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; + } + 75% { + box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, + 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; + } + 87.5% { + box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; + } +} + +/* LikeButton */ +.like-button { + outline-offset: 2px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + cursor: pointer; + border-radius: 9999px; + border: none; + outline: none 2px; + color: #5e687e; + background: none; +} + +.like-button:focus { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); +} + +.like-button:active { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); + transform: scaleX(0.95) scaleY(0.95); +} + +.like-button:hover { + background-color: #f6f7f9; +} + +.like-button.liked { + color: #a6423a; +} + +/* Icons */ +@keyframes circle { + 0% { + transform: scale(0); + stroke-width: 16px; + } + + 50% { + transform: scale(.5); + stroke-width: 16px; + } + + to { + transform: scale(1); + stroke-width: 0; + } +} + +.circle { + color: rgba(166, 66, 58, .5); + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4,0,.2,1); +} + +.circle.liked.animate { + animation: circle .3s forwards; +} + +.heart { + width: 1.5rem; + height: 1.5rem; +} + +.heart.liked { + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4, 0, .2, 1); +} + +.heart.liked.animate { + animation: scale .35s ease-in-out forwards; +} + +.control-icon { + color: hsla(0, 0%, 100%, .5); + filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); +} + +.chevron-left { + margin-top: 2px; + rotate: 90deg; +} + + +/* Video */ +.thumbnail { + position: relative; + aspect-ratio: 16 / 9; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + outline-offset: 2px; + width: 8rem; + vertical-align: middle; + background-color: #ffffff; + background-size: cover; + user-select: none; +} + +.thumbnail.blue { + background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); +} + +.thumbnail.red { + background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); +} + +.thumbnail.green { + background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); +} + +.thumbnail.purple { + background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); +} + +.thumbnail.yellow { + background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); +} + +.thumbnail.gray { + background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); +} + +.video { + display: flex; + flex-direction: row; + gap: 0.75rem; + align-items: center; +} + +.video .link { + display: flex; + flex-direction: row; + flex: 1 1 0; + gap: 0.125rem; + outline-offset: 4px; + cursor: pointer; +} + +.video .info { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 8px; + gap: 0.125rem; +} + +.video .info:hover { + text-decoration: underline; +} + +.video-title { + font-size: 15px; + line-height: 1.25; + font-weight: 700; + color: #23272f; +} + +.video-description { + color: #5e687e; + font-size: 13px; +} + +/* Details */ +.details .thumbnail { + position: relative; + aspect-ratio: 16 / 9; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + outline-offset: 2px; + width: 100%; + vertical-align: middle; + background-color: #ffffff; + background-size: cover; + user-select: none; +} + +.video-details-title { + margin-top: 8px; +} + +.video-details-speaker { + display: flex; + gap: 8px; + margin-top: 10px +} + +.back { + display: flex; + align-items: center; + margin-left: -5px; + cursor: pointer; +} + +.back:hover { + text-decoration: underline; +} + +.info-title { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; + margin: 8px 0 0 0 ; +} + +.info-description { + margin: 8px 0 0 0; +} + +.controls { + cursor: pointer; +} + +.fallback { + background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; + background-size: 800px 104px; + display: block; + line-height: 1.25; + margin: 8px 0 0 0; + border-radius: 5px; + overflow: hidden; + + animation: 1s linear 1s infinite shimmer; + animation-delay: 300ms; + animation-duration: 1s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: shimmer; + animation-timing-function: linear; +} + + +.fallback.title { + width: 130px; + height: 30px; + +} + +.fallback.description { + width: 150px; + height: 21px; +} + +@keyframes shimmer { + 0% { + background-position: -468px 0; + } + + 100% { + background-position: 468px 0; + } +} + +.search { + margin-bottom: 10px; +} +.search-input { + width: 100%; + position: relative; +} +``` + +```css +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 { + + +```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; +} + +::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" + } +} +``` + + + +### Анимация списков {/*animating-lists*/} + +Вы также можете использовать `` для анимации списков элементов при их переупорядочивании, например, в списке элементов с поиском: + +```js {3,5} +
+ {filteredVideos.map((video) => ( + + + ))} +
+``` + +Чтобы активировать ViewTransition, мы можем использовать `useDeferredValue`: + +```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(); + + // Default slow-fade animation. + 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}) { + // Animate from Suspense fallback to content + return ( + + + + } + > + {/* Animate the content up */} + + + + + ); +} + +function VideoInfoFallback() { + return ( + <> +
+
+ + ); +} + +export default function Details() { + const { url, navigateBack } = useRouter(); + const videoId = url.split("/").pop(); + const video = use(fetchVideo(videoId)); + + return ( + { + navigateBack("/"); + }} + > + Back +
+ } + > +
+ + + + +
+ + ); +} + +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}) { + // Activate with useDeferredValue ("when") + const deferredSearchText = useDeferredValue(searchText); + const filteredVideos = filterVideos(videos, deferredSearchText); + return ( +
+
+ {filteredVideos.map((video) => ( + // Animate each item in list ("what") + + + ))} +
+ {filteredVideos.length === 0 && ( +
No results
+ )} +
+ ); +} + +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); + + return ( + {count} Videos
}> + + + + ); +} + +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); +} + +function filterVideos(videos, query) { + const keywords = query + .toLowerCase() + .split(" ") + .filter((s) => s !== ""); + if (keywords.length === 0) { + return videos; + } + return videos.filter((video) => { + const words = (video.title + " " + video.description) + .toLowerCase() + .split(" "); + return keywords.every((kw) => words.some((w) => w.includes(kw))); + }); +} +``` + +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); +} + +export function PauseIcon() { + return ( + + + + ); +} + +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 && } +
+
+ {/* Отказ от ViewTransition для контента. */} + {/* Контент может определить свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); +} +``` + +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; + +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. +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, 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 }) { + // Добавьте имя для анимации с помощью перехода с общим элементом. + // Это использует анимацию по умолчанию, дополнительный CSS не требуется. + 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, 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]); + + 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; +} +``` + +```css +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 */ + + + + + + +/* Slide animation for Suspense */ +::view-transition-old(.slide-down) { + animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; +} + +::view-transition-new(.slide-up) { + animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +} + +/* Animations for view transition classed added by transition type */ +::view-transition-old(.slide-forward) { + /* when sliding forward, the "old" page should slide out to left. */ + animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, + 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; +} + +::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 slide-up { + from { + transform: translateY(10px); + } + to { + transform: translateY(0); + } +} + +@keyframes slide-down { + from { + transform: translateY(0); + } + to { + transform: translateY(10px); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } +} + +@keyframes fade-out { + to { + opacity: 0; + } +} + +@keyframes slide-to-right { + to { + transform: translateX(50px); + } +} + +@keyframes slide-from-right { + from { + transform: translateX(50px); + } + to { + transform: translateX(0); + } +} + +@keyframes slide-to-left { + to { + transform: translateX(-50px); + } +} + +@keyframes slide-from-left { + from { + transform: translateX(-50px); + } + to { + transform: translateX(0); + } +} + + +/* Default .slow-fade. */ +::view-transition-old(.slow-fade) { + animation-duration: 500ms; +} + +::view-transition-new(.slow-fade) { + animation-duration: 500ms; +} +``` + +```js src/index.js hidden +import React, {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import './styles.css'; +import './animations.css'; + +import App from './App'; +import {Router} from './router'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + + + +); +``` + +```json package.json hidden +{ + "dependencies": { + "react": "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. + +Давайте уберём медленное затухание и посмотрим на конечный результат: + + + +```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(); + + // Анимируем страницы с помощью перекрестного затухания. + return ( + + {url === '/' ? :
} + + ); +} +``` + +```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"; + +function VideoDetails({id}) { + // Анимируем от запасного варианта Suspense до контента + return ( + + + + } + > + {/* Анимируем контент вверх */} + + + + + ); +} + +function VideoInfoFallback() { + return ( + <> +
+
+ + ); +} + +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} Видео
}> + + + + ); +} + +function SearchInput({ value, onChange }) { + const id = useId(); + return ( +
e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+
+ ); +} + +function filterVideos(videos, query) { + const keywords = query + .toLowerCase() + .split(" ") + .filter((s) => s !== ""); + if (keywords.length === 0) { + return videos; + } + return videos.filter((video) => { + const words = (video.title + " " + video.description) + .toLowerCase() + .split(" "); + return keywords.every((kw) => words.some((w) => w.includes(kw))); + }); +} +``` + +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); +} + +export function PauseIcon() { + return ( + + + + ); +} + +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 && } +
+
+ {/* Отказ от ViewTransition для контента. */} + {/* Контент может определять свой собственный 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 +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 ( + + + + ); +} + + + +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); + + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); +} + +export function Video({ video }) { + const { navigate } = useRouter(); + + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + + +
+
{video.title}
+
{video.description}
+
+
+ +
+ ); +} +``` + + +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'First video', + description: 'Video description', + image: 'blue', + }, + { + id: '2', + title: 'Second video', + description: 'Video description', + image: 'red', + }, + { + id: '3', + title: 'Third video', + description: 'Video description', + image: 'green', + }, + { + id: '4', + title: 'Fourth video', + description: 'Video description', + image: 'purple', + }, + { + id: '5', + title: 'Fifth video', + description: 'Video description', + image: 'yellow', + }, + { + id: '6', + title: 'Sixth video', + description: 'Video description', + image: 'gray', + }, +]; + +let videosCache = new Map(); +let videoCache = new Map(); +let videoDetailsCache = new Map(); +const VIDEO_DELAY = 1; +const VIDEO_DETAILS_DELAY = 1000; +export function fetchVideos() { + if (videosCache.has(0)) { + return videosCache.get(0); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos); + }, VIDEO_DELAY); + }); + videosCache.set(0, promise); + return promise; +} + +export function fetchVideo(id) { + if (videoCache.has(id)) { + return videoCache.get(id); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos.find((video) => video.id === id)); + }, VIDEO_DELAY); + }); + videoCache.set(id, promise); + return promise; +} + +export function fetchVideoDetails(id) { + if (videoDetailsCache.has(id)) { + return videoDetailsCache.get(id); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos.find((video) => video.id === id)); + }, VIDEO_DETAILS_DELAY); + }); + videoDetailsCache.set(id, promise); + return promise; +} +``` + +```js src/router.js +import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react"; + +export function Router({ children }) { + const [isPending, startTransition] = useTransition(); + function navigate(url) { + startTransition(() => { + // Transition type for the cause "nav forward" + addTransitionType('nav-forward'); + go(url); + }); + } + function navigateBack(url) { + startTransition(() => { + // Transition type for the cause "nav backward" + addTransitionType('nav-back'); + go(url); + }); + } + + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + + useEffect(() => { + function handlePopState() { + // This should not animate because restoration has to be synchronous. + // Even though it's a transition. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Noop. URL has already updated. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); + + return ( + + {children} + + ); +} + +const RouterContext = createContext({ url: "/", params: {} }); + +export function useRouter() { + return use(RouterContext); +} + +export function useIsNavPending() { + return use(RouterContext).isPending; +} +``` + +```css src/styles.css hidden +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +* { + box-sizing: border-box; +} + +html { + background-image: url(https://react.dev/images/meta-gradient-dark.png); + background-size: 100%; + background-position: -100%; + background-color: rgb(64 71 86); + background-repeat: no-repeat; + height: 100%; + width: 100%; +} + +body { + font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + padding: 10px 0 10px 0; + margin: 0; + display: flex; + justify-content: center; +} + +#root { + flex: 1 1; + height: auto; + background-color: #fff; + border-radius: 10px; + max-width: 450px; + min-height: 600px; + padding-bottom: 10px; +} + +h1 { + margin-top: 0; + font-size: 22px; +} + +h2 { + margin-top: 0; + font-size: 20px; +} + +h3 { + margin-top: 0; + font-size: 18px; +} + +h4 { + margin-top: 0; + font-size: 16px; +} + +h5 { + margin-top: 0; + font-size: 14px; +} + +h6 { + margin-top: 0; + font-size: 12px; +} + +code { + font-size: 1.2em; +} + +ul { + padding-inline-start: 20px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.absolute { + position: absolute; +} + +.overflow-visible { + overflow: visible; +} + +.visible { + overflow: visible; +} + +.fit { + width: fit-content; +} + + +/* Layout */ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.top-hero { + height: 200px; + display: flex; + justify-content: center; + align-items: center; + background-image: conic-gradient( + from 90deg at -10% 100%, + #2b303b 0deg, + #2b303b 90deg, + #16181d 1turn + ); +} + +.bottom { + flex: 1; + overflow: auto; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0; + padding: 0 12px; + top: 0; + width: 100%; + height: 44px; + color: #23272f; + font-weight: 700; + font-size: 20px; + z-index: 100; + cursor: default; +} + +.content { + padding: 0 12px; + margin-top: 4px; +} + + +.loader { + color: #23272f; + font-size: 3px; + width: 1em; + margin-right: 18px; + height: 1em; + border-radius: 50%; + position: relative; + text-indent: -9999em; + animation: loading-spinner 1.3s infinite linear; + animation-delay: 200ms; + transform: translateZ(0); +} + +@keyframes loading-spinner { + 0%, + 100% { + box-shadow: 0 -3em 0 0.2em, + 2em -2em 0 0em, 3em 0 0 -1em, + 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 0; + } + 12.5% { + box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, + 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 -1em; + } + 25% { + box-shadow: 0 -3em 0 -0.5em, + 2em -2em 0 0, 3em 0 0 0.2em, + 2em 2em 0 0, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 -1em; + } + 37.5% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, + -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; + } + 50% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, + -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; + } + 62.5% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, + -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; + } + 75% { + box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, + 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; + } + 87.5% { + box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; + } +} + +/* LikeButton */ +.like-button { + outline-offset: 2px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + cursor: pointer; + border-radius: 9999px; + border: none; + outline: none 2px; + color: #5e687e; + background: none; +} + +.like-button:focus { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); +} + +.like-button:active { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); + transform: scaleX(0.95) scaleY(0.95); +} + +.like-button:hover { + background-color: #f6f7f9; +} + +.like-button.liked { + color: #a6423a; +} + +/* Icons */ +@keyframes circle { + 0% { + transform: scale(0); + stroke-width: 16px; + } + + 50% { + transform: scale(.5); + stroke-width: 16px; + } + + to { + transform: scale(1); + stroke-width: 0; + } +} + +.circle { + color: rgba(166, 66, 58, .5); + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4,0,.2,1); +} + +.circle.liked.animate { + animation: circle .3s forwards; +} + +.heart { + width: 1.5rem; + height: 1.5rem; +} + +.heart.liked { + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4, 0, .2, 1); +} + +.heart.liked.animate { + animation: scale .35s ease-in-out forwards; +} + +.control-icon { + color: hsla(0, 0%, 100%, .5); + filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); +} + +.chevron-left { + margin-top: 2px; + rotate: 90deg; +} + + +/* Video */ +.thumbnail { + position: relative; + aspect-ratio: 16 / 9; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + outline-offset: 2px; + width: 8rem; + vertical-align: middle; + background-color: #ffffff; + background-size: cover; + user-select: none; +} + +.thumbnail.blue { + background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); +} + +.thumbnail.red { + background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); +} + +.thumbnail.green { + background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); +} + +.thumbnail.purple { + background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); +} + +.thumbnail.yellow { + background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); +} + +.thumbnail.gray { + background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); +} + +.video { + display: flex; + flex-direction: row; + gap: 0.75rem; + align-items: center; +} + +.video .link { + display: flex; + flex-direction: row; + flex: 1 1 0; + gap: 0.125rem; + outline-offset: 4px; + cursor: pointer; +} + +.video .info { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 8px; + gap: 0.125rem; +} + +.video .info:hover { + text-decoration: underline; +} + +.video-title { + font-size: 15px; + line-height: 1.25; + font-weight: 700; + color: #23272f; +} + +.video-description { + color: #5e687e; + font-size: 13px; +} + +/* Details */ +.details .thumbnail { + position: relative; + aspect-ratio: 16 / 9; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + outline-offset: 2px; + width: 100%; + vertical-align: middle; + background-color: #ffffff; + background-size: cover; + user-select: none; +} + +.video-details-title { + margin-top: 8px; +} + +.video-details-speaker { + display: flex; + gap: 8px; + margin-top: 10px +} + +.back { + display: flex; + align-items: center; + margin-left: -5px; + cursor: pointer; +} + +.back:hover { + text-decoration: underline; +} + +.info-title { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; + margin: 8px 0 0 0 ; +} + +.info-description { + margin: 8px 0 0 0; +} + +.controls { + cursor: pointer; +} + +.fallback { + background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; + background-size: 800px 104px; + display: block; + line-height: 1.25; + margin: 8px 0 0 0; + border-radius: 5px; + overflow: hidden; + + animation: 1s linear 1s infinite shimmer; + animation-delay: 300ms; + animation-duration: 1s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: shimmer; + animation-timing-function: linear; +} + + +.fallback.title { + width: 130px; + height: 30px; + +} + +.fallback.description { + width: 150px; + height: 21px; +} + +@keyframes shimmer { + 0% { + background-position: -468px 0; + } + + 100% { + background-position: 468px 0; + } +} + +.search { + margin-bottom: 10px; +} +.search-input { + width: 100%; + position: relative; +} +``` + +```css +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 +/* Анимации для плавного появления/исчезновения при Suspense */ +::view-transition-old(.slide-down) { + animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; +} + +::view-transition-new(.slide-up) { + animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +} + +/* Анимации для переходов с классами, основанными на типе перехода */ +::view-transition-old(.slide-forward) { + /* При движении вперед "старая" страница должна уходить влево. */ + 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) { + /* При движении вперед "новая" страница должна появляться справа. */ + 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) { + /* При движении назад "старая" страница должна уходить вправо. */ + 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) { + /* При движении назад "новая" страница должна появляться слева. */ + 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 slide-up { + from { + transform: translateY(10px); + } + to { + transform: translateY(0); + } +} + +@keyframes slide-down { + from { + transform: translateY(0); + } + to { + transform: translateY(10px); + } +} + +@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); + } +} +``` + +```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" + } +} +``` + +
+ +Если вам интересно узнать больше о том, как они работают, ознакомьтесь с разделом [Как работает ``](/reference/react/ViewTransition#how-does-viewtransition-work) в документации. + +_Для получения дополнительной информации о том, как мы реализовали 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*/} + +В [предыдущих](/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. + +Теперь мы готовы представить API и объяснить, как он работает, чтобы вы могли начать тестировать его в экспериментальных версиях React. + +`` — это новый компонент для скрытия и отображения частей пользовательского интерфейса: + +```js [[1, 1, "'visible'"], [2, 1, "'hidden'"]] + + + +``` + +Когда компонент Activity видим, он рендерится как обычно. Когда компонент Activity скрыт, он размонтируется, но сохранит своё состояние и продолжит рендериться с более низким приоритетом, чем всё, что видно на экране. + +Вы можете использовать `Activity` для сохранения состояния частей пользовательского интерфейса, которые пользователь не использует в данный момент, или для предварительного рендеринга частей, которые пользователь, вероятно, будет использовать следующими. + +Рассмотрим несколько примеров, улучшающих приведённые выше примеры View Transition. + + + +**Эффекты не монтируются, когда компонент Activity скрыт.** + +Когда `` имеет значение `hidden`, эффекты размонтируются. Концептуально компонент размонтируется, но React сохраняет его состояние для последующего использования. + +На практике это работает ожидаемо, если вы следовали руководству [Вам может не понадобиться эффект](/learn/you-might-not-need-an-effect). Чтобы быстро выявлять проблемные эффекты, мы рекомендуем добавить [``](/reference/react/StrictMode), который будет принудительно выполнять размонтирование и монтирование компонентов Activity, чтобы выявить любые неожиданные побочные эффекты. + + + +### Восстановление состояния с помощью Activity {/*restoring-state-with-activity*/} + +Когда + +```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 ( +
+
+
+ {/* Custom classes based on transition type. */} + + {heading} + + {isPending && } +
+
+ {/* Opt-out of ViewTransition for the content. */} + {/* Content can define it's own ViewTransition. */} + +
+
{children}
+
+
+
+ ); +} +``` + +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; + +// A hack since we don't actually have a backend. +// Unlike local state, this survives videos being filtered. +const likedVideos = new Set(); + +export default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + return ( + + ); +} +``` + +```js src/Videos.js hidden +import { useState, 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, 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(() => { + // 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); + }); + } + + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); + } + + useEffect(() => { + function handlePopState() { + // This should not animate because restoration has to be synchronous. + // Even though it's a transition. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Noop. URL has already updated. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); + + return ( + + {children} + + ); +} + +const RouterContext = createContext({ url: "/", params: {} }); + +export function useRouter() { + return use(RouterContext); +} + +export function useIsNavPending() { + return use(RouterContext).isPending; +} +``` + +```css src/styles.css hidden +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Optimistic Text; + src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +* { + box-sizing: border-box; +} + +html { + background-image: url(https://react.dev/images/meta-gradient-dark.png); + background-size: 100%; + background-position: -100%; + background-color: rgb(64 71 86); + background-repeat: no-repeat; + height: 100%; + width: 100%; +} + +body { + font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + padding: 10px 0 10px 0; + margin: 0; + display: flex; + justify-content: center; +} + +#root { + flex: 1 1; + height: auto; + background-color: #fff; + border-radius: 10px; + max-width: 450px; + min-height: 600px; + padding-bottom: 10px; +} + +h1 { + margin-top: 0; + font-size: 22px; +} + +h2 { + margin-top: 0; + font-size: 20px; +} + +h3 { + margin-top: 0; + font-size: 18px; +} + +h4 { + margin-top: 0; + font-size: 16px; +} + +h5 { + margin-top: 0; + font-size: 14px; +} + +h6 { + margin-top: 0; + font-size: 12px; +} + +code { + font-size: 1.2em; +} + +ul { + padding-inline-start: 20px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.absolute { + position: absolute; +} + +.overflow-visible { + overflow: visible; +} + +.visible { + overflow: visible; +} + +.fit { + width: fit-content; +} + + +/* Layout */ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.top-hero { + height: 200px; + display: flex; + justify-content: center; + align-items: center; + background-image: conic-gradient( + from 90deg at -10% 100%, + #2b303b 0deg, + #2b303b 90deg, + #16181d 1turn + ); +} + +.bottom { + flex: 1; + overflow: auto; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0; + padding: 0 12px; + top: 0; + width: 100%; + height: 44px; + color: #23272f; + font-weight: 700; + font-size: 20px; + z-index: 100; + cursor: default; +} + +.content { + padding: 0 12px; + margin-top: 4px; +} + + +.loader { + color: #23272f; + font-size: 3px; + width: 1em; + margin-right: 18px; + height: 1em; + border-radius: 50%; + position: relative; + text-indent: -9999em; + animation: loading-spinner 1.3s infinite linear; + animation-delay: 200ms; + transform: translateZ(0); +} + +@keyframes loading-spinner { + 0%, + 100% { + box-shadow: 0 -3em 0 0.2em, + 2em -2em 0 0em, 3em 0 0 -1em, + 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 0; + } + 12.5% { + box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, + 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 -1em; + } + 25% { + box-shadow: 0 -3em 0 -0.5em, + 2em -2em 0 0, 3em 0 0 0.2em, + 2em 2em 0 0, 0 3em 0 -1em, + -2em 2em 0 -1em, -3em 0 0 -1em, + -2em -2em 0 -1em; + } + 37.5% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, + -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; + } + 50% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, + -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; + } + 62.5% { + box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, + -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; + } + 75% { + box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, + 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; + } + 87.5% { + box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, + 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, + -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; + } +} + +/* LikeButton */ +.like-button { + outline-offset: 2px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + cursor: pointer; + border-radius: 9999px; + border: none; + outline: none 2px; + color: #5e687e; + background: none; +} + +.like-button:focus { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); +} + +.like-button:active { + color: #a6423a; + background-color: rgba(166, 66, 58, .05); + transform: scaleX(0.95) scaleY(0.95); +} + +.like-button:hover { + background-color: #f6f7f9; +} + +.like-button.liked { + color: #a6423a; +} + +/* Icons */ +@keyframes circle { + 0% { + transform: scale(0); + stroke-width: 16px; + } + + 50% { + transform: scale(.5); + stroke-width: 16px; + } + + to { + transform: scale(1); + stroke-width: 0; + } +} + +.circle { + color: rgba(166, 66, 58, .5); + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4,0,.2,1); +} + +.circle.liked.animate { + animation: circle .3s forwards; +} + +.heart { + width: 1.5rem; + height: 1.5rem; +} + +.heart.liked { + transform-origin: center; + transition-property: all; + transition-duration: .15s; + transition-timing-function: cubic-bezier(.4, 0, .2, 1); +} + +.heart.liked.animate { + animation: scale .35s ease-in-out forwards; +} + +.control-icon { + color: hsla(0, 0%, 100%, .5); + filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); +} + +.chevron-left { + margin-top: 2px; + rotate: 90deg; +} + + +/* Video */ +.thumbnail { + position: relative; + aspect-ratio: 16 / 9; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + outline-offset: 2px; + width: 8rem; + vertical-align: middle; + background-color: #ffffff; + background-size: cover; + user-select: none; +} + +.thumbnail.blue { + background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); +} + +.thumbnail.red { + background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); +} + +.thumbnail.green { + background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); +} + +.thumbnail.purple { + background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); +} + +.thumbnail.yellow { + background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); +} + +.thumbnail.gray { + background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); +} + +.video { + display: flex; + flex-direction: row; + gap: 0.75rem; + align-items: center; +} + +.video .link { + display: flex; + flex-direction: row; + flex: 1 1 0; + gap: 0.125rem; + outline-offset: 4px; + cursor: pointer; +} + +.video .info { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 8px; + gap: 0.125rem; +} + +.video .info:hover { + text-decoration: underline; +} + +.video-title { + font-size: 15px; + line-height: 1.25; + font-weight: 700; + color: #23272f; +} + +.video-description { + color: #5e687e; + font-size: 13px; +} + +/* Details */ +.details .thumbnail { + position: relative; + aspect-ratio: 16 / 9; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 0.5rem; + outline-offset: 2px; + width: 100%; + vertical-align: middle; + background-color: #ffffff; + background-size: cover; + user-select: none; +} + +.video-details-title { + margin-top: 8px; +} + +.video-details-speaker { + display: flex; + gap: 8px; + margin-top: 10px +} + +.back { + display: flex; + align-items: center; + margin-left: -5px; + cursor: pointer; +} + +.back:hover { + text-decoration: underline; +} + +.info-title { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; + margin: 8px 0 0 0 ; +} + +.info-description { + margin: 8px 0 0 0; +} + +.controls { + cursor: pointer; +} + +.fallback { + background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; + background-size: 800px 104px; + display: block; + line-height: 1.25; + margin: 8px 0 0 0; + border-radius: 5px; + overflow: hidden; + + animation: 1s linear 1s infinite shimmer; + animation-delay: 300ms; + animation-duration: 1s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: shimmer; + animation-timing-function: linear; +} + + +.fallback.title { + width: 130px; + height: 30px; + +} + +.fallback.description { + width: 150px; + height: 21px; +} + +@keyframes shimmer { + 0% { + background-position: -468px 0; + } + + 100% { + background-position: 468px 0; + } +} + +.search { + margin-bottom: 10px; +} +.search-input { + width: 100%; + position: relative; +} +``` + +```css +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 */ + + + + + + +/* 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; +} + +::view-transition-new(.slide-up) { + animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +} + +/* Animations for view transition classed added by transition type */ +::view-transition-old(.slide-forward) { + /* when sliding forward, the "old" page should slide out to left. */ + animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, + 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; +} + +::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 slide-up { + from { + transform: translateY(10px); + } + to { + transform: translateY(0); + } +} + +@keyframes slide-down { + from { + transform: translateY(0); + } + to { + transform: translateY(10px); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } +} + +@keyframes fade-out { + to { + opacity: 0; + } +} + +@keyframes slide-to-right { + to { + transform: translateX(50px); + } +} + +@keyframes slide-from-right { + from { + transform: translateX(50px); + } + to { + transform: translateX(0); + } +} + +@keyframes slide-to-left { + to { + transform: translateX(-50px); + } +} + +@keyframes slide-from-left { + from { + transform: translateX(-50px); + } + to { + transform: translateX(0); + } +} + +/* Default .slow-fade. */ +::view-transition-old(.slow-fade) { + animation-duration: 500ms; +} + +::view-transition-new(.slow-fade) { + animation-duration: 500ms; +} +``` + +```js src/index.js hidden +import React, {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import './styles.css'; +import './animations.css'; + +import App from './App'; +import {Router} from './router'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + + + +); +``` + +```json package.json hidden +{ + "dependencies": { + "react": "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" + } +} +``` + + + +### Предварительный рендеринг с помощью Activity {/*prerender-with-activity*/} + +Иногда вы можете захотеть заранее подготовить следующую часть пользовательского интерфейса, к которой пользователь, скорее всего, обратится, чтобы она была готова к моменту, когда он будет готов её использовать. Это особенно полезно, если следующий маршрут должен приостановиться для загрузки данных, необходимых для рендеринга, поскольку вы можете помочь обеспечить получение данных до того, как пользователь перейдёт на новый маршрут. + +Например, наше приложение в настоящее время должно приостанавливаться для загрузки данных для каждого видео при его выборе. Мы можем улучшить это, отрендерив все страницы в скрытом `` до тех пор, пока пользователь не перейдёт на новый маршрут: + +```js {2,5,8} + + + + + +
+ +