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..8646227557 100644 --- a/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md +++ b/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md @@ -2,36 +2,36 @@ title: "React Labs: View Transitions, Activity, and more" author: Ricky Hanlon date: 2025/04/23 -description: In React Labs posts, we write about projects in active research and development. In this post, we're sharing two new experimental features that are ready to try today, and updates on other areas we're working on now. +description: В публикациях React Labs мы пишем о проектах, находящихся в стадии активных исследований и разработок. В этой статье мы представляем две новые экспериментальные функции, готовые к тестированию сегодня, а также обновления по другим направлениям, над которыми мы сейчас работаем. --- -April 23, 2025 by [Ricky Hanlon](https://twitter.com/rickhanlonii) +23 апреля 2025 г. [Ricky Hanlon](https://twitter.com/rickhanlonii) --- -In React Labs posts, we write about projects in active research and development. In this post, we're sharing two new experimental features that are ready to try today, and updates on other areas we're working on now. +В публикациях React Labs мы пишем о проектах, находящихся в активном исследовании и разработке. В этой публикации мы представляем две новые экспериментальные функции, готовые к тестированию, а также обновления по другим областям, над которыми мы работаем в настоящее время. -React Conf 2025 is scheduled for October 7–8 in Henderson, Nevada! +React Conf 2025 запланирована на 7–8 октября в Хендерсоне, штат Невада! -We're looking for speakers to help us create talks about the features covered in this post. If you're interested in speaking at ReactConf, [please apply here](https://forms.reform.app/react-conf/call-for-speakers/) (no talk proposal required). +Мы ищем докладчиков, которые помогут нам создать выступления о функциях, описанных в этой публикации. Если вы заинтересованы в выступлении на ReactConf, [пожалуйста, подайте заявку здесь](https://forms.reform.app/react-conf/call-for-speakers/) (предварительное предложение доклада не требуется). -For more info on tickets, free streaming, sponsoring, and more, see [the React Conf website](https://conf.react.dev). +Для получения дополнительной информации о билетах, бесплатной трансляции, спонсорстве и многом другом посетите [веб-сайт React Conf](https://conf.react.dev). -Today, we're excited to release documentation for two new experimental features that are ready for testing: +Сегодня мы рады опубликовать документацию для двух новых экспериментальных функций, готовых к тестированию: - [View Transitions](#view-transitions) - [Activity](#activity) -We're also sharing updates on new features currently in development: +Мы также делимся обновлениями о новых функциях, находящихся в разработке: - [React Performance Tracks](#react-performance-tracks) - [Compiler IDE Extension](#compiler-ide-extension) - [Automatic Effect Dependencies](#automatic-effect-dependencies) @@ -40,57 +40,57 @@ We're also sharing updates on new features currently in development: --- -# New Experimental Features {/*new-experimental-features*/} +# Новые экспериментальные функции {/*new-experimental-features*/} -View Transitions and Activity are now ready for testing in `react@experimental`. These features have been tested in production and are stable, but the final API may still change as we incorporate feedback. +View Transitions и Activity теперь готовы к тестированию в `react@experimental`. Эти функции были протестированы в продакшене и являются стабильными, но окончательный API может измениться по мере внесения обратной связи. -You can try them by upgrading React packages to the most recent experimental version: +Вы можете попробовать их, обновив пакеты React до последней экспериментальной версии: - `react@experimental` - `react-dom@experimental` -Read on to learn how to use these features in your app, or check out the newly published docs: +Читайте дальше, чтобы узнать, как использовать эти функции в вашем приложении, или ознакомьтесь с недавно опубликованной документацией: -- [``](/reference/react/ViewTransition): A component that lets you activate an animation for a Transition. -- [`addTransitionType`](/reference/react/addTransitionType): A function that allows you to specify the cause of a Transition. -- [``](/reference/react/Activity): A component that lets you hide and show parts of the UI. +- [``](/reference/react/ViewTransition): Компонент, который позволяет активировать анимацию для перехода. +- [`addTransitionType`](/reference/react/addTransitionType): Функция, которая позволяет указать причину перехода. +- [``](/reference/react/Activity): Компонент, который позволяет скрывать и отображать части пользовательского интерфейса. -## View Transitions {/*view-transitions*/} +## Переходы между представлениями {/*view-transitions*/} -React View Transitions are a new experimental feature that makes it easier to add animations to UI transitions in your app. Under-the-hood, these animations use the new [`startViewTransition`](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) API available in most modern browsers. +Переходы между представлениями в React — это новая экспериментальная функция, которая упрощает добавление анимаций к переходам пользовательского интерфейса в вашем приложении. По сути, эти анимации используют новый API [`startViewTransition`](https://developer.mozilla.org/ru/docs/Web/API/Document/startViewTransition), доступный в большинстве современных браузеров. -To opt-in to animating an element, wrap it in the new `` component: +Чтобы включить анимацию элемента, оберните его в новый компонент ``: ```js -// "what" to animate. +// "Что" анимировать. -
animate me
+
Анимируй меня
``` -This new component lets you declaratively define "what" to animate when an animation is activated. +Этот новый компонент позволяет декларативно определять, "что" анимировать при активации анимации. -You can define "when" to animate by using one of these three triggers for a View Transition: +Вы можете определить, "когда" анимировать, используя один из следующих трех триггеров для перехода между представлениями: ```js -// "when" to animate. +// "Когда" анимировать. -// Transitions +// Переходы startTransition(() => setState(...)); -// Deferred Values +// Отложенные значения const deferred = useDeferredValue(value); // Suspense }> -
Loading...
+
Загрузка...
``` -By default, these animations use the [default CSS animations for View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations) applied (typically a smooth cross-fade). You can use [view transition pseudo-selectors](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#the_view_transition_pseudo-element_tree) to define "how" the animation runs. For example, you can use `*` to change the default animation for all transitions: +По умолчанию эти анимации используют [CSS-анимации по умолчанию для переходов между представлениями](https://developer.mozilla.org/ru/docs/Web/API/View_Transition_API/Using#customizing_your_animations) (обычно плавное перекрестное затухание). Вы можете использовать [псевдоселекторы переходов между представлениями](https://developer.mozilla.org/ru/docs/Web/API/View_Transition_API/Using#the_view_transition_pseudo-element_tree), чтобы определить, "как" выполняется анимация. Например, вы можете использовать `*`, чтобы изменить анимацию по умолчанию для всех переходов: ``` -// "how" to animate. +// "Как" анимировать. ::view-transition-old(*) { animation: 300ms ease-out fade-out; } @@ -99,16 +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 переходов между представлениями браузера и хотите узнать, как React его поддерживает, ознакомьтесь с разделом [Как работает ``](/reference/react/ViewTransition#how-does-viewtransition-work) в документации. -In this post, let's take a look at a few examples of how to use View Transitions. +В этой статье мы рассмотрим несколько примеров использования переходов между представлениями. -We'll start with this app, which doesn't animate any of the following interactions: -- Click a video to view the details. -- Click "back" to go back to the feed. -- Type in the list to filter the videos. +Мы начнем с этого приложения, которое не анимирует ни одно из следующих взаимодействий: +- Нажмите на видео, чтобы просмотреть детали. +- Нажмите "назад", чтобы вернуться к ленте. +- Введите текст в списке, чтобы отфильтровать видео. @@ -118,7 +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,12 +164,12 @@ 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) => ( @@ -396,8 +396,8 @@ export default function Page({ heading, children }) { import {useState} from 'react'; import {Heart} from './Icons'; -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -406,7 +406,7 @@ export default function LikeButton({video}) { return (
} > @@ -1387,7 +1385,7 @@ function SearchInput({ value, onChange }) { return ( e.preventDefault()}>
@@ -1396,7 +1394,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -1427,11 +1425,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) => ( @@ -1538,7 +1536,7 @@ export function Heart({liked, animate}) { )} @@ -1577,8 +1575,8 @@ export default function Page({ heading, children }) { {isPending && }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Отключить ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -1593,8 +1591,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}) { @@ -1603,7 +1601,7 @@ export default function LikeButton({video}) { return (
- } - > -
- - - - }> - - -
- - ); -} - -``` - -```js src/Home.js hidden -import { Video } from "./Videos"; -import Layout from "./Layout"; -import { fetchVideos } from "./data"; -import { useId, useState, use } from "react"; -import { IconSearch } from "./Icons"; - -function SearchInput({ value, onChange }) { - const id = useId(); - return ( - e.preventDefault()}> - -
-
- -
- onChange(e.target.value)} - /> -
- - ); -} - -function filterVideos(videos, query) { - const keywords = query - .toLowerCase() - .split(" ") - .filter((s) => s !== ""); - if (keywords.length === 0) { - return videos; - } - return videos.filter((video) => { - const words = (video.title + " " + video.description) - .toLowerCase() - .split(" "); - return keywords.every((kw) => words.some((w) => w.includes(kw))); - }); -} - -export default function Home() { - const videos = use(fetchVideos()); - const count = videos.length; - const [searchText, setSearchText] = useState(""); - const foundVideos = filterVideos(videos, searchText); - return ( - {count} Videos
}> - -
- {foundVideos.length === 0 && ( -
No results
- )} -
- {foundVideos.map((video) => ( -
-
- - ); -} - -``` - -```js src/Icons.js hidden -export function ChevronLeft() { - return ( - - - - - - - ); -} - -export function PauseIcon() { - return ( - - - - ); -} - -export function PlayIcon() { - return ( - - - - ); -} -export function Heart({liked, animate}) { - return ( - <> - - - - - - {liked ? ( - - ) : ( - - )} - - - ); -} - -export function IconSearch(props) { - return ( - - - - ); -} -``` - -```js src/Layout.js hidden -import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; - -export default function Page({ heading, children }) { - const isPending = useIsNavPending(); - - return ( -
-
-
- {heading} - {isPending && } -
-
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} - -
-
{children}
-
-
-
- ); -} -``` - -```js src/LikeButton.js hidden -import {useState} from 'react'; -import {Heart} from './Icons'; - -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. -const likedVideos = new Set(); - -export default function LikeButton({video}) { - const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); - const [animate, setAnimate] = useState(false); - return ( - - ); -} -``` - -```js src/Videos.js hidden -import { useState } from "react"; -import LikeButton from "./LikeButton"; -import { useRouter } from "./router"; -import { PauseIcon, PlayIcon } from "./Icons"; -import { startTransition } from "react"; - -export function VideoControls() { - const [isPlaying, setIsPlaying] = useState(false); - - return ( - - startTransition(() => { - setIsPlaying((p) => !p); - }) - } - > - {isPlaying ? : } - - ); -} - -export function Thumbnail({ video, children }) { - return ( - - ); -} - -export function Video({ video }) { - const { navigate } = useRouter(); - - return ( -
-
{ - e.preventDefault(); - navigate(`/video/${video.id}`); - }} - > - - -
-
{video.title}
-
{video.description}
-
-
- -
- ); -} -``` - - -```js src/data.js hidden -const videos = [ - { - id: '1', - title: 'First video', - description: 'Video description', - image: 'blue', - }, - { - id: '2', - title: 'Second video', - description: 'Video description', - image: 'red', - }, - { - id: '3', - title: 'Third video', - description: 'Video description', - image: 'green', - }, - { - id: '4', - title: 'Fourth video', - description: 'Video description', - image: 'purple', - }, - { - id: '5', - title: 'Fifth video', - description: 'Video description', - image: 'yellow', - }, - { - id: '6', - title: 'Sixth video', - description: 'Video description', - image: 'gray', - }, -]; - -let videosCache = new Map(); -let videoCache = new Map(); -let videoDetailsCache = new Map(); -const VIDEO_DELAY = 1; -const VIDEO_DETAILS_DELAY = 1000; -export function fetchVideos() { - if (videosCache.has(0)) { - return videosCache.get(0); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos); - }, VIDEO_DELAY); - }); - videosCache.set(0, promise); - return promise; -} - -export function fetchVideo(id) { - if (videoCache.has(id)) { - return videoCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DELAY); - }); - videoCache.set(id, promise); - return promise; -} - -export function fetchVideoDetails(id) { - if (videoDetailsCache.has(id)) { - return videoDetailsCache.get(id); - } - const promise = new Promise((resolve) => { - setTimeout(() => { - resolve(videos.find((video) => video.id === id)); - }, VIDEO_DETAILS_DELAY); - }); - videoDetailsCache.set(id, promise); - return promise; -} -``` - -```js src/router.js hidden -import { - useState, - createContext, - use, - useTransition, - useLayoutEffect, - useEffect, -} from "react"; - -const RouterContext = createContext({ url: "/", params: {} }); - -export function useRouter() { - return use(RouterContext); -} - -export function useIsNavPending() { - return use(RouterContext).isPending; -} - -export function Router({ children }) { - const [routerState, setRouterState] = useState({ - pendingNav: () => {}, - url: document.location.pathname, - }); - const [isPending, startTransition] = useTransition(); - - function go(url) { - setRouterState({ - url, - pendingNav() { - window.history.pushState({}, "", url); - }, - }); - } - function navigate(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - function navigateBack(url) { - // Update router state in transition. - startTransition(() => { - go(url); - }); - } - - useEffect(() => { - function handlePopState() { - // This should not animate because restoration has to be synchronous. - // Even though it's a transition. - startTransition(() => { - setRouterState({ - url: document.location.pathname + document.location.search, - pendingNav() { - // Noop. URL has already updated. - }, - }); - }); - } - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - const pendingNav = routerState.pendingNav; - useLayoutEffect(() => { - pendingNav(); - }, [pendingNav]); - - return ( - - {children} - - ); -} -``` - -```css src/styles.css hidden -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: Optimistic Text; - src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -* { - box-sizing: border-box; -} - -html { - background-image: url(https://react.dev/images/meta-gradient-dark.png); - background-size: 100%; - background-position: -100%; - background-color: rgb(64 71 86); - background-repeat: no-repeat; - height: 100%; - width: 100%; -} - -body { - font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; - padding: 10px 0 10px 0; - margin: 0; - display: flex; - justify-content: center; -} - -#root { - flex: 1 1; - height: auto; - background-color: #fff; - border-radius: 10px; - max-width: 450px; - min-height: 600px; - padding-bottom: 10px; -} - -h1 { - margin-top: 0; - font-size: 22px; -} - -h2 { - margin-top: 0; - font-size: 20px; -} - -h3 { - margin-top: 0; - font-size: 18px; -} - -h4 { - margin-top: 0; - font-size: 16px; -} - -h5 { - margin-top: 0; - font-size: 14px; -} - -h6 { - margin-top: 0; - font-size: 12px; -} - -code { - font-size: 1.2em; -} - -ul { - padding-inline-start: 20px; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.absolute { - position: absolute; -} - -.overflow-visible { - overflow: visible; -} - -.visible { - overflow: visible; -} - -.fit { - width: fit-content; -} - - -/* Layout */ -.page { - display: flex; - flex-direction: column; - height: 100%; -} - -.top-hero { - height: 200px; - display: flex; - justify-content: center; - align-items: center; - background-image: conic-gradient( - from 90deg at -10% 100%, - #2b303b 0deg, - #2b303b 90deg, - #16181d 1turn - ); -} - -.bottom { - flex: 1; - overflow: auto; -} - -.top-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0; - padding: 0 12px; - top: 0; - width: 100%; - height: 44px; - color: #23272f; - font-weight: 700; - font-size: 20px; - z-index: 100; - cursor: default; -} - -.content { - padding: 0 12px; - margin-top: 4px; -} - - -.loader { - color: #23272f; - font-size: 3px; - width: 1em; - margin-right: 18px; - height: 1em; - border-radius: 50%; - position: relative; - text-indent: -9999em; - animation: loading-spinner 1.3s infinite linear; - animation-delay: 200ms; - transform: translateZ(0); -} - -@keyframes loading-spinner { - 0%, - 100% { - box-shadow: 0 -3em 0 0.2em, - 2em -2em 0 0em, 3em 0 0 -1em, - 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 0; - } - 12.5% { - box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, - 3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 25% { - box-shadow: 0 -3em 0 -0.5em, - 2em -2em 0 0, 3em 0 0 0.2em, - 2em 2em 0 0, 0 3em 0 -1em, - -2em 2em 0 -1em, -3em 0 0 -1em, - -2em -2em 0 -1em; - } - 37.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em, - -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 50% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em, - -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em; - } - 62.5% { - box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0, - -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em; - } - 75% { - box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, - 3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0; - } - 87.5% { - box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, - 3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em, - -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em; - } -} - -/* LikeButton */ -.like-button { - outline-offset: 2px; - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - cursor: pointer; - border-radius: 9999px; - border: none; - outline: none 2px; - color: #5e687e; - background: none; -} - -.like-button:focus { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); -} - -.like-button:active { - color: #a6423a; - background-color: rgba(166, 66, 58, .05); - transform: scaleX(0.95) scaleY(0.95); -} - -.like-button:hover { - background-color: #f6f7f9; -} - -.like-button.liked { - color: #a6423a; -} - -/* Icons */ -@keyframes circle { - 0% { - transform: scale(0); - stroke-width: 16px; - } - - 50% { - transform: scale(.5); - stroke-width: 16px; - } - - to { - transform: scale(1); - stroke-width: 0; - } -} - -.circle { - color: rgba(166, 66, 58, .5); - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4,0,.2,1); -} - -.circle.liked.animate { - animation: circle .3s forwards; -} - -.heart { - width: 1.5rem; - height: 1.5rem; -} - -.heart.liked { - transform-origin: center; - transition-property: all; - transition-duration: .15s; - transition-timing-function: cubic-bezier(.4, 0, .2, 1); -} - -.heart.liked.animate { - animation: scale .35s ease-in-out forwards; -} - -.control-icon { - color: hsla(0, 0%, 100%, .5); - filter: drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08)); -} - -.chevron-left { - margin-top: 2px; - rotate: 90deg; -} - - -/* Video */ -.thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 8rem; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.thumbnail.blue { - background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491); -} - -.thumbnail.red { - background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491); -} - -.thumbnail.green { - background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491); -} - -.thumbnail.purple { - background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491); -} - -.thumbnail.yellow { - background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491); -} - -.thumbnail.gray { - background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491); -} - -.video { - display: flex; - flex-direction: row; - gap: 0.75rem; - align-items: center; -} - -.video .link { - display: flex; - flex-direction: row; - flex: 1 1 0; - gap: 0.125rem; - outline-offset: 4px; - cursor: pointer; -} - -.video .info { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 8px; - gap: 0.125rem; -} - -.video .info:hover { - text-decoration: underline; -} - -.video-title { - font-size: 15px; - line-height: 1.25; - font-weight: 700; - color: #23272f; -} - -.video-description { - color: #5e687e; - font-size: 13px; -} - -/* Details */ -.details .thumbnail { - position: relative; - aspect-ratio: 16 / 9; - display: flex; - overflow: hidden; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 0.5rem; - outline-offset: 2px; - width: 100%; - vertical-align: middle; - background-color: #ffffff; - background-size: cover; - user-select: none; -} - -.video-details-title { - margin-top: 8px; -} - -.video-details-speaker { - display: flex; - gap: 8px; - margin-top: 10px -} - -.back { - display: flex; - align-items: center; - margin-left: -5px; - cursor: pointer; -} - -.back:hover { - text-decoration: underline; -} - -.info-title { - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; - margin: 8px 0 0 0 ; -} - -.info-description { - margin: 8px 0 0 0; -} - -.controls { - cursor: pointer; -} - -.fallback { - background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat; - background-size: 800px 104px; - display: block; - line-height: 1.25; - margin: 8px 0 0 0; - border-radius: 5px; - overflow: hidden; - - animation: 1s linear 1s infinite shimmer; - animation-delay: 300ms; - animation-duration: 1s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: shimmer; - animation-timing-function: linear; -} - - -.fallback.title { - width: 130px; - height: 30px; - -} - -.fallback.description { - width: 150px; - height: 21px; -} - -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } -} - -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; -} - -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; -} - -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; -} - -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} - -/* Home */ -.video-list { - position: relative; -} - -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; -} -``` - - -```css src/animations.css -/* Define .slow-fade using view transition classes */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; -} -``` - -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` - -```json package.json hidden -{ - "dependencies": { - "react": "experimental", - "react-dom": "experimental", - "react-scripts": "latest" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" - } -} -``` - - - -See [Styling View Transitions](/reference/react/ViewTransition#styling-view-transitions) for a full guide on styling ``. - -### Shared Element Transitions {/*shared-element-transitions*/} - -When two pages include the same element, often you want to animate it from one page to the next. - -To do this you can add a unique `name` to the ``: - -```js - - - -``` - -Now the video thumbnail animates between the two pages: - - - -```js src/App.js -import { unstable_ViewTransition as ViewTransition } from "react"; -import Details from "./Details"; -import Home from "./Home"; -import { useRouter } from "./router"; - -export default function App() { - const { url } = useRouter(); - - // Keeping our default slow-fade. - // This allows the content not in the shared - // element transition to cross-fade. - return ( - - {url === "/" ? :
} - - ); -} -``` - -```js src/Details.js hidden -import { fetchVideo, fetchVideoDetails } from "./data"; -import { Thumbnail, VideoControls } from "./Videos"; -import { useRouter } from "./router"; -import Layout from "./Layout"; -import { use, Suspense } from "react"; -import { ChevronLeft } from "./Icons"; - -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

- - ); -} - -function VideoInfoFallback() { - return ( - <> -
-
- - ); +function VideoInfoFallback() { + return ( + <> +
+
+ + ); } export default function Details() { @@ -3765,12 +1965,12 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад
} >
- + }> @@ -3795,7 +1995,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -3804,7 +2004,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -3835,11 +2035,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) => ( @@ -3946,7 +2146,7 @@ export function Heart({liked, animate}) { )} @@ -3985,8 +2185,8 @@ export default function Page({ heading, children }) { {isPending && }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Опт-аут из ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -4001,8 +2201,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}) { @@ -4011,7 +2211,7 @@ export default function LikeButton({video}) { return (
} > @@ -5050,7 +3188,7 @@ function SearchInput({ value, onChange }) { return ( e.preventDefault()}>
@@ -5059,7 +3197,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -5090,11 +3228,11 @@ export default function Home() { const [searchText, setSearchText] = useState(""); const foundVideos = filterVideos(videos, searchText); return ( - {count} Videos
}> + {count} Видео
}>
{foundVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{foundVideos.map((video) => ( @@ -5201,7 +3339,7 @@ export function Heart({liked, animate}) { )} @@ -5226,29 +3364,22 @@ 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 && }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Исключаем ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -5263,8 +3394,8 @@ export default function Page({ heading, children }) { import {useState} from 'react'; import {Heart} from './Icons'; -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -5273,7 +3404,7 @@ export default function LikeButton({video}) { 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}) { + // Активация с помощью useDeferredValue ("когда") + const deferredSearchText = useDeferredValue(searchText); + const filteredVideos = filterVideos(videos, deferredSearchText); + return ( +
+
+ {filteredVideos.map((video) => ( + // Анимация каждого элемента в списке ("что") + + + ))} +
+ {filteredVideos.length === 0 && ( +
Нет результатов
+ )} +
+ ); +} +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); + + return ( + {count} Видео
}> + + + + ); +} -/* Slide animation for Suspense */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; +function SearchInput({ value, onChange }) { + const id = useId(); + return ( + e.preventDefault()}> + +
+
+ +
+ onChange(e.target.value)} + /> +
+ + ); } -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +function filterVideos(videos, query) { + const keywords = query + .toLowerCase() + .split(" ") + .filter((s) => s !== ""); + if (keywords.length === 0) { + return videos; + } + return videos.filter((video) => { + const words = (video.title + " " + video.description) + .toLowerCase() + .split(" "); + return keywords.every((kw) => words.some((w) => w.includes(kw))); + }); } +``` -/* Animations for view transition classed added by transition type */ -::view-transition-old(.slide-forward) { - /* when sliding forward, the "old" page should slide out to left. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); } -::view-transition-new(.slide-forward) { - /* when sliding forward, the "new" page should slide in from right. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; +export function PauseIcon() { + return ( + + + + ); } -::view-transition-old(.slide-back) { - /* when sliding back, the "old" page should slide out to right. */ - animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right; +export function PlayIcon() { + return ( + + + + ); } +export function Heart({liked, animate}) { + return ( + <> + + + -::view-transition-new(.slide-back) { - /* when sliding back, the "new" page should slide in from left. */ - animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in, - 400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left; + + {liked ? ( + + ) : ( + + )} + + + ); } -/* Keyframes to support our animations above. */ -@keyframes slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } +export function IconSearch(props) { + return ( + + + + ); } +``` -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } -} +```js src/Layout.js hidden +import {unstable_ViewTransition as ViewTransition} from 'react'; +import { useIsNavPending } from "./router"; -@keyframes fade-in { - from { - opacity: 0; - } +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Пользовательские классы в зависимости от типа перехода. */} + + {heading} + + {isPending && } +
+
+ {/* Отключение ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); } +``` -@keyframes fade-out { - to { - opacity: 0; - } -} +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. +const likedVideos = new Set(); -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } +export default function LikeButton({video}) { + const [isLiked, setIsLiked] = useState(() => likedVideos.has(video.id)); + const [animate, setAnimate] = useState(false); + return ( + + ); } +``` -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } -} +```js src/Videos.js hidden +import { useState, unstable_ViewTransition as ViewTransition } from "react"; +import LikeButton from "./LikeButton"; +import { useRouter } from "./router"; +import { PauseIcon, PlayIcon } from "./Icons"; +import { startTransition } from "react"; -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } +export function Thumbnail({ video, children }) { + // Добавьте имя для анимации с помощью перехода с общим элементом. + // Это использует анимацию по умолчанию, дополнительный CSS не требуется. + return ( + + + + ); } +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); -/* Default .slow-fade. */ -::view-transition-old(.slow-fade) { - animation-duration: 500ms; -} - -::view-transition-new(.slow-fade) { - animation-duration: 500ms; + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); } -``` - -```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'; +export function Video({ video }) { + const { navigate } = useRouter(); -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + -```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" - } +
+
{video.title}
+
{video.description}
+
+
+ +
+ ); } ``` - -### Final result {/*final-result*/} +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'Первое видео', + description: 'Описание видео', + image: 'blue', + }, + { + id: '2', + title: 'Второе видео', + description: 'Описание видео', + image: 'red', + }, + { + id: '3', + title: 'Третье видео', + description: 'Описание видео', + image: 'green', + }, + { + id: '4', + title: 'Четвертое видео', + description: 'Описание видео', + image: 'purple', + }, + { + id: '5', + title: 'Пятое видео', + description: 'Описание видео', + image: 'yellow', + }, + { + id: '6', + title: 'Шестое видео', + description: 'Описание видео', + image: 'gray', + }, +]; -By adding a few `` components and a few lines of CSS, we were able to add all the animations above into the final result. +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; +} -We're excited about View Transitions and think they will level up the apps you're able to build. They're ready to start trying today in the experimental channel of React releases. +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; +} -Let's remove the slow fade, and take a look at the final result: +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"; -```js src/App.js -import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; +export function 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); + }); + } -export default function App() { - const {url} = useRouter(); + 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]); - // Animate with a cross fade between pages. return ( - - {url === '/' ? :
} - + + {children} + ); } -``` -```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"; +const RouterContext = createContext({ url: "/", params: {} }); -function VideoDetails({id}) { - // Animate from Suspense fallback to content - return ( - - - - } - > - {/* Animate the content up */} - - - - - ); +export function useRouter() { + return use(RouterContext); } -function VideoInfoFallback() { - return ( - <> -
-
- - ); +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; } -export default function Details() { - const { url, navigateBack } = useRouter(); - const videoId = url.split("/").pop(); - const video = use(fetchVideo(videoId)); +@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; +} - return ( - { - navigateBack("/"); - }} - > - Back -
- } - > -
- - - - -
- - ); +@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; } -function VideoInfo({ id }) { - const details = use(fetchVideoDetails(id)); - return ( - <> -

{details.title}

-

{details.description}

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

{details.title}

+

{details.description}

+ + ); } +``` -.controls { - cursor: pointer; +```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 && ( +
Нет результатов
+ )} +
+ ); } -.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; +export default function Home() { + const videos = use(fetchVideos()); + const count = videos.length; + const [searchText, setSearchText] = useState(''); + + return ( + {count} Видео}> + + + + ); +} - 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; +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))); + }); +} +``` -.fallback.title { - width: 130px; - height: 30px; +```js src/Icons.js hidden +export function ChevronLeft() { + return ( + + + + + + + ); +} +export function PauseIcon() { + return ( + + + + ); } -.fallback.description { - width: 150px; - height: 21px; +export function PlayIcon() { + return ( + + + + ); } +export function Heart({liked, animate}) { + return ( + <> + + + -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - - 100% { - background-position: 468px 0; - } + + {liked ? ( + + ) : ( + + )} + + + ); } -.search { - margin-bottom: 10px; -} -.search-input { - width: 100%; - position: relative; +export function IconSearch(props) { + return ( + + + + ); } +``` -.search-icon { - position: absolute; - top: 0; - bottom: 0; - inset-inline-start: 0; - display: flex; - align-items: center; - padding-inline-start: 1rem; - pointer-events: none; - color: #99a1b3; -} +```js src/Layout.js +import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; -.search-input input { - display: flex; - padding-inline-start: 2.75rem; - padding-top: 10px; - padding-bottom: 10px; - width: 100%; - text-align: start; - background-color: rgb(235 236 240); - outline: 2px solid transparent; - cursor: pointer; - border: none; - align-items: center; - color: rgb(35 39 47); - border-radius: 9999px; - vertical-align: middle; - font-size: 15px; +export default function Page({ heading, children }) { + const isPending = useIsNavPending(); + return ( +
+
+
+ {/* Пользовательские классы в зависимости от типа перехода. */} + + {heading} + + {isPending && } +
+
+ {/* Отказ от ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */} + +
+
{children}
+
+
+
+ ); } +``` -.search-input input:hover, .search-input input:active { - background-color: rgb(235 236 240/ 0.8); - color: rgb(35 39 47/ 0.8); -} +```js src/LikeButton.js hidden +import {useState} from 'react'; +import {Heart} from './Icons'; -/* Home */ -.video-list { - position: relative; -} +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это сохраняется при фильтрации видео. +const likedVideos = new Set(); -.video-list .videos { - display: flex; - flex-direction: column; - gap: 1rem; - overflow-y: auto; - height: 100%; +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"; -```css src/animations.css -/* Slide animations for Suspense the fallback down */ -::view-transition-old(.slide-down) { - animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down; -} - -::view-transition-new(.slide-up) { - animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up; +export function Thumbnail({ video, children }) { + // Добавляем имя для анимации с помощью перехода с общим элементом. + return ( + + + + ); } -/* 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; -} +export function VideoControls() { + const [isPlaying, setIsPlaying] = useState(false); -::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; + return ( + + startTransition(() => { + setIsPlaying((p) => !p); + }) + } + > + {isPlaying ? : } + + ); } -/* Keyframes to support our animations above. */ -@keyframes slide-up { - from { - transform: translateY(10px); - } - to { - transform: translateY(0); - } -} +export function Video({ video }) { + const { navigate } = useRouter(); -@keyframes slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(10px); - } -} + return ( +
+
{ + e.preventDefault(); + navigate(`/video/${video.id}`); + }} + > + -@keyframes fade-in { - from { - opacity: 0; - } +
+
{video.title}
+
{video.description}
+
+
+ +
+ ); } +``` -@keyframes fade-out { - to { - opacity: 0; - } -} -@keyframes slide-to-right { - to { - transform: translateX(50px); - } -} +```js src/data.js hidden +const videos = [ + { + id: '1', + title: 'Первое видео', + description: 'Описание видео', + image: 'blue', + }, + { + id: '2', + title: 'Второе видео', + description: 'Описание видео', + image: 'red', + }, + { + id: '3', + title: 'Третье видео', + description: 'Описание видео', + image: 'green', + }, + { + id: '4', + title: 'Четвертое видео', + description: 'Описание видео', + image: 'purple', + }, + { + id: '5', + title: 'Пятое видео', + description: 'Описание видео', + image: 'yellow', + }, + { + id: '6', + title: 'Шестое видео', + description: 'Описание видео', + image: 'gray', + }, +]; -@keyframes slide-from-right { - from { - transform: translateX(50px); - } - to { - transform: translateX(0); - } +let videosCache = new Map(); +let videoCache = new Map(); +let videoDetailsCache = new Map(); +const VIDEO_DELAY = 1; +const VIDEO_DETAILS_DELAY = 1000; +export function fetchVideos() { + if (videosCache.has(0)) { + return videosCache.get(0); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos); + }, VIDEO_DELAY); + }); + videosCache.set(0, promise); + return promise; } -@keyframes slide-to-left { - to { - transform: translateX(-50px); - } +export function fetchVideo(id) { + if (videoCache.has(id)) { + return videoCache.get(id); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos.find((video) => video.id === id)); + }, VIDEO_DELAY); + }); + videoCache.set(id, promise); + return promise; } -@keyframes slide-from-left { - from { - transform: translateX(-50px); - } - to { - transform: translateX(0); - } +export function fetchVideoDetails(id) { + if (videoDetailsCache.has(id)) { + return videoDetailsCache.get(id); + } + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve(videos.find((video) => video.id === id)); + }, VIDEO_DETAILS_DELAY); + }); + videoDetailsCache.set(id, promise); + return promise; } ``` -```js src/index.js hidden -import React, {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import './styles.css'; -import './animations.css'; - -import App from './App'; -import {Router} from './router'; +```js src/router.js +import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react"; -const root = createRoot(document.getElementById('root')); -root.render( - - - - - -); -``` +export function Router({ children }) { + const [isPending, startTransition] = useTransition(); + function navigate(url) { + startTransition(() => { + // Тип перехода для причины "nav forward" + addTransitionType('nav-forward'); + go(url); + }); + } + function navigateBack(url) { + startTransition(() => { + // Тип перехода для причины "nav backward" + addTransitionType('nav-back'); + go(url); + }); + } -```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" + const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname}); + + function go(url) { + setRouterState({ + url, + pendingNav() { + window.history.pushState({}, "", url); + }, + }); } + + useEffect(() => { + function handlePopState() { + // Это не должно анимироваться, так как восстановление должно быть синхронным. + // Даже если это переход. + startTransition(() => { + setRouterState({ + url: document.location.pathname + document.location.search, + pendingNav() { + // Ничего не делаем. URL уже обновлен. + }, + }); + }); + } + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + const pendingNav = routerState.pendingNav; + useLayoutEffect(() => { + pendingNav(); + }, [pendingNav]); + + return ( + + {children} + + ); } -``` -
+const RouterContext = createContext({ url: "/", params: {} }); + +export function useRouter() { + return use(RouterContext); +} -If you're curious to know more about how they work, check out [How Does `` Work](/reference/react/ViewTransition#how-does-viewtransition-work) in the docs. +export function useIsNavPending() { + return use(RouterContext).isPending; +} + +Если вам интересно узнать больше о том, как они работают, ознакомьтесь с разделом [Как работает ``](/reference/react/ViewTransition#how-does-viewtransition-work) в документации. -_For more background on how we built View Transitions, see: [#31975](https://github.com/facebook/react/pull/31975), [#32105](https://github.com/facebook/react/pull/32105), [#32041](https://github.com/facebook/react/pull/32041), [#32734](https://github.com/facebook/react/pull/32734), [#32797](https://github.com/facebook/react/pull/32797) [#31999](https://github.com/facebook/react/pull/31999), [#32031](https://github.com/facebook/react/pull/32031), [#32050](https://github.com/facebook/react/pull/32050), [#32820](https://github.com/facebook/react/pull/32820), [#32029](https://github.com/facebook/react/pull/32029), [#32028](https://github.com/facebook/react/pull/32028), and [#32038](https://github.com/facebook/react/pull/32038) by [@sebmarkbage](https://twitter.com/sebmarkbage) (thanks Seb!)._ +_Для получения дополнительной информации о том, как мы реализовали View Transitions, см.: [#31975](https://github.com/facebook/react/pull/31975), [#32105](https://github.com/facebook/react/pull/32105), [#32041](https://github.com/facebook/react/pull/32041), [#32734](https://github.com/facebook/react/pull/32734), [#32797](https://github.com/facebook/react/pull/32797) [#31999](https://github.com/facebook/react/pull/31999), [#32031](https://github.com/facebook/react/pull/32031), [#32050](https://github.com/facebook/react/pull/32050), [#32820](https://github.com/facebook/react/pull/32820), [#32029](https://github.com/facebook/react/pull/32029), [#32028](https://github.com/facebook/react/pull/32028) и [#32038](https://github.com/facebook/react/pull/32038) от [@sebmarkbage](https://twitter.com/sebmarkbage) (спасибо, Себ!)._ --- -## Activity {/*activity*/} +## Активность {/*activity*/} -In [past](/blog/2022/06/15/react-labs-what-we-have-been-working-on-june-2022#offscreen) [updates](/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024#offscreen-renamed-to-activity), we shared that we were researching an API to allow components to be visually hidden and deprioritized, preserving UI state with reduced performance costs relative to unmounting or hiding with CSS. +В [предыдущих](/blog/2022/06/15/react-labs-what-we-have-been-working-on-june-2022#offscreen) [обновлениях](/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024#offscreen-renamed-to-activity) мы рассказывали об исследовании API, который позволял бы визуально скрывать компоненты и снижать их приоритет, сохраняя состояние пользовательского интерфейса с меньшими затратами производительности по сравнению с размонтированием или скрытием с помощью CSS. -We're now ready to share the API and how it works, so you can start testing it in experimental React versions. +Теперь мы готовы представить API и объяснить, как он работает, чтобы вы могли начать тестировать его в экспериментальных версиях React. -`` is a new component to hide and show parts of the UI: +`` — это новый компонент для скрытия и отображения частей пользовательского интерфейса: ```js [[1, 1, "'visible'"], [2, 1, "'hidden'"]] @@ -11477,25 +10152,25 @@ We're now ready to share the API and how it works, so you can start testing it i ``` -When an Activity is visible it's rendered as normal. When an Activity is hidden it is unmounted, but will save its state and continue to render at a lower priority than anything visible on screen. +Когда `` видим, он отображается как обычно. Когда `` скрыт, он размонтируется, но сохранит своё состояние и продолжит рендериться с более низким приоритетом, чем всё, что видно на экране. -You can use `Activity` to save state for parts of the UI the user isn't using, or pre-render parts that a user is likely to use next. +Вы можете использовать `Activity` для сохранения состояния частей пользовательского интерфейса, которые пользователь не использует в данный момент, или для предварительного рендеринга частей, которые пользователь, вероятно, будет использовать следующими. -Let's look at some examples improving the View Transition examples above. +Рассмотрим несколько примеров, улучшающих примеры переходов между представлениями, приведённые выше. -**Effects don’t mount when an Activity is hidden.** +**Эффекты не монтируются, когда `` скрыт.** -When an `` is `hidden`, Effects are unmounted. Conceptually, the component is unmounted, but React saves the state for later. +Когда `` имеет значение `hidden`, эффекты размонтируются. Концептуально компонент размонтируется, но React сохраняет его состояние для последующего использования. -In practice, this works as expected if you have followed the [You Might Not Need an Effect](/learn/you-might-not-need-an-effect) guide. To eagerly find problematic Effects, we recommend adding [``](/reference/react/StrictMode) which will eagerly perform Activity unmounts and mounts to catch any unexpected side effects. +На практике это работает ожидаемо, если вы следовали руководству [Вам может не понадобиться эффект](/learn/you-might-not-need-an-effect). Чтобы быстро выявлять проблемные эффекты, мы рекомендуем добавить [``](/reference/react/StrictMode), который будет принудительно выполнять размонтирование и монтирование ``, чтобы выявить любые неожиданные побочные эффекты. -### Restoring state with Activity {/*restoring-state-with-activity*/} +### Восстановление состояния с помощью Activity {/*restoring-state-with-activity*/} -When a user navigates away from a page, it's common to stop rendering the old page: +Когда пользователь уходит со страницы, часто прекращают рендеринг старой страницы: ```js {6,7} function App() { @@ -11510,9 +10185,9 @@ function App() { } ``` -However, this means if the user goes back to the old page, all of the previous state is lost. For example, if the `` page has an `` field, when the user leaves the page the `` is unmounted, and all of the text they had typed is lost. +Однако это означает, что если пользователь вернется на старую страницу, всё предыдущее состояние будет потеряно. Например, если страница `` содержит поле ``, то при уходе пользователя со страницы `` будет размонтирован, и весь набранный текст будет потерян. -Activity allows you to keep the state around as the user changes pages, so when they come back they can resume where they left off. This is done by wrapping part of the tree in `` and toggling the `mode`: +Activity позволяет сохранить состояние при смене страниц, чтобы пользователь мог продолжить с того места, где остановился. Это достигается путем оборачивания части дерева в `` и переключения `mode`: ```js {6-8} function App() { @@ -11529,9 +10204,9 @@ function App() { } ``` -With this change, we can improve on our View Transitions example above. Before, when you searched for a video, selected one, and returned, your search filter was lost. With Activity, your search filter is restored and you can pick up where you left off. +С этим изменением мы можем улучшить наш предыдущий пример с View Transitions. Раньше, когда вы искали видео, выбирали его и возвращались, ваш фильтр поиска терялся. С Activity ваш фильтр поиска восстанавливается, и вы можете продолжить с того места, где остановились. -Try searching for a video, selecting it, and clicking "back": +Попробуйте поискать видео, выбрать его и нажать "назад": @@ -11542,9 +10217,9 @@ export default function App() { const { url } = useRouter(); return ( - // View Transitions know about Activity + // View Transitions знают об Activity - {/* Render Home in Activity so we don't lose state */} + {/* Рендерим Home в Activity, чтобы не потерять состояние */} @@ -11563,17 +10238,17 @@ import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; function VideoDetails({id}) { - // Animate from Suspense fallback to content + // Анимация от запасного варианта Suspense к контенту return ( } > - {/* Animate the content up */} + {/* Анимация контента вверх */} @@ -11604,12 +10279,12 @@ export default function Details() { navigateBack("/"); }} > - Back + Назад } >
- + @@ -11633,17 +10308,17 @@ function VideoInfo({ id }) { import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; function SearchList({searchText, videos}) { - // Activate with useDeferredValue ("when") + // Активация с помощью useDeferredValue ("когда") const deferredSearchText = useDeferredValue(searchText); const filteredVideos = filterVideos(videos, deferredSearchText); return (
{filteredVideos.length === 0 && ( -
No results
+
Нет результатов
)}
{filteredVideos.map((video) => ( - // Animate each item in list ("what") + // Анимация каждого элемента списка ("что") @@ -11659,7 +10334,7 @@ export default function Home() { const [searchText, setSearchText] = useState(''); return ( - {count} Videos
}> + {count} Видео
}> @@ -11671,7 +10346,7 @@ function SearchInput({ value, onChange }) { return (
e.preventDefault()}>
@@ -11680,7 +10355,7 @@ function SearchInput({ value, onChange }) { onChange(e.target.value)} /> @@ -11799,7 +10474,7 @@ export function Heart({liked, animate}) { )} @@ -11833,7 +10508,7 @@ export default function Page({ heading, children }) {
- {/* Custom classes based on transition type. */} + {/* Пользовательские классы в зависимости от типа перехода. */} }
- {/* Opt-out of ViewTransition for the content. */} - {/* Content can define it's own ViewTransition. */} + {/* Отключение ViewTransition для контента. */} + {/* Контент может определять свой собственный ViewTransition. */}
{children}
@@ -11861,8 +10536,8 @@ export default function Page({ heading, children }) { import {useState} from 'react'; import {Heart} from './Icons'; -// A hack since we don't actually have a backend. -// Unlike local state, this survives videos being filtered. +// Хак, так как у нас нет реального бэкенда. +// В отличие от локального состояния, это переживает фильтрацию видео. const likedVideos = new Set(); export default function LikeButton({video}) { @@ -11871,7 +10546,7 @@ export default function LikeButton({video}) { return (