diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml
index b1ef428d0..83e7f2e8a 100644
--- a/.github/workflows/analyze.yml
+++ b/.github/workflows/analyze.yml
@@ -7,6 +7,8 @@ on:
       - main # change this if your default branch is named differently
   workflow_dispatch:
 
+permissions: {}
+
 jobs:
   analyze:
     runs-on: ubuntu-latest
@@ -23,7 +25,7 @@ jobs:
       - name: Restore cached node_modules
         uses: actions/cache@v4
         with:
-          path: "**/node_modules"
+          path: '**/node_modules'
           key: node_modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
 
       - name: Install deps
@@ -55,7 +57,7 @@ jobs:
           name: bundle_analysis.json
 
       - name: Download base branch bundle stats
-        uses: dawidd6/action-download-artifact@v2
+        uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e
         if: success() && github.event.number
         with:
           workflow: analyze.yml
diff --git a/.github/workflows/analyze_comment.yml b/.github/workflows/analyze_comment.yml
index 5a3047cfc..1e086b9b7 100644
--- a/.github/workflows/analyze_comment.yml
+++ b/.github/workflows/analyze_comment.yml
@@ -2,10 +2,12 @@ name: Analyze Bundle (Comment)
 
 on:
   workflow_run:
-    workflows: ["Analyze Bundle"]
+    workflows: ['Analyze Bundle']
     types:
       - completed
 
+permissions: {}
+
 jobs:
   comment:
     runs-on: ubuntu-latest
@@ -14,7 +16,7 @@ jobs:
       github.event.workflow_run.conclusion == 'success' }}
     steps:
       - name: Download base branch bundle stats
-        uses: dawidd6/action-download-artifact@v2
+        uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e
         with:
           workflow: analyze.yml
           run_id: ${{ github.event.workflow_run.id }}
@@ -22,7 +24,7 @@ jobs:
           path: analysis_comment.txt
 
       - name: Download PR number
-        uses: dawidd6/action-download-artifact@v2
+        uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e
         with:
           workflow: analyze.yml
           run_id: ${{ github.event.workflow_run.id }}
@@ -48,7 +50,7 @@ jobs:
           echo "pr-number=$pr_number" >> $GITHUB_OUTPUT
 
       - name: Comment
-        uses: marocchino/sticky-pull-request-comment@v2
+        uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728
         with:
           header: next-bundle-analysis
           number: ${{ steps.get-comment-body.outputs.pr-number }}
diff --git a/.github/workflows/discord_notify.yml b/.github/workflows/discord_notify.yml
index a4b8c9137..2f5b2a497 100644
--- a/.github/workflows/discord_notify.yml
+++ b/.github/workflows/discord_notify.yml
@@ -4,12 +4,16 @@ on:
   pull_request_target:
     types: [opened, ready_for_review]
 
+permissions: {}
+
 jobs:
   check_maintainer:
     uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main
+    permissions:
+      # Used by check_maintainer
+      contents: read
     with:
       actor: ${{ github.event.pull_request.user.login }}
-      is_remote: true
 
   notify:
     if: ${{ needs.check_maintainer.outputs.is_core_team == 'true' }}
diff --git a/.github/workflows/label_core_team_prs.yml b/.github/workflows/label_core_team_prs.yml
index 3d9fa2be1..f9b3328ee 100644
--- a/.github/workflows/label_core_team_prs.yml
+++ b/.github/workflows/label_core_team_prs.yml
@@ -3,6 +3,8 @@ name: Label Core Team PRs
 on:
   pull_request_target:
 
+permissions: {}
+
 env:
   TZ: /usr/share/zoneinfo/America/Los_Angeles
   # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout
@@ -11,14 +13,21 @@ env:
 jobs:
   check_maintainer:
     uses: facebook/react/.github/workflows/shared_check_maintainer.yml@main
+    permissions:
+      # Used by check_maintainer
+      contents: read
     with:
       actor: ${{ github.event.pull_request.user.login }}
-      is_remote: true
 
   label:
     if: ${{ needs.check_maintainer.outputs.is_core_team == 'true' }}
     runs-on: ubuntu-latest
     needs: check_maintainer
+    permissions:
+      # Used to add labels on issues
+      issues: write
+      # Used to add labels on PRs
+      pull-requests: write
     steps:
       - name: Label PR as React Core Team
         uses: actions/github-script@v7
diff --git a/.github/workflows/site_lint.yml b/.github/workflows/site_lint.yml
index 36f7642c9..81a04601c 100644
--- a/.github/workflows/site_lint.yml
+++ b/.github/workflows/site_lint.yml
@@ -7,6 +7,8 @@ on:
   pull_request:
     types: [opened, synchronize, reopened]
 
+permissions: {}
+
 jobs:
   lint:
     runs-on: ubuntu-latest
@@ -25,7 +27,7 @@ jobs:
       - name: Restore cached node_modules
         uses: actions/cache@v4
         with:
-          path: "**/node_modules"
+          path: '**/node_modules'
           key: node_modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
 
       - name: Install deps
diff --git a/next-env.d.ts b/next-env.d.ts
index 3cd7048ed..52e831b43 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,5 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-/// <reference types="next/navigation-types/compat/navigation" />
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
diff --git a/postcss.config.js b/postcss.config.js
index 427294522..d55c43c90 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -17,4 +17,4 @@ module.exports = {
       },
     },
   },
-}
+};
diff --git a/public/fonts/Source-Code-Pro-Bold.woff2 b/public/fonts/Source-Code-Pro-Bold.woff2
new file mode 100644
index 000000000..220bd5d96
Binary files /dev/null and b/public/fonts/Source-Code-Pro-Bold.woff2 differ
diff --git a/public/fonts/Source-Code-Pro-Regular.woff2 b/public/fonts/Source-Code-Pro-Regular.woff2
index 655cd9e81..fd665c465 100644
Binary files a/public/fonts/Source-Code-Pro-Regular.woff2 and b/public/fonts/Source-Code-Pro-Regular.woff2 differ
diff --git a/public/images/blog/react-labs-april-2025/perf_tracks.png b/public/images/blog/react-labs-april-2025/perf_tracks.png
new file mode 100644
index 000000000..835a247cf
Binary files /dev/null and b/public/images/blog/react-labs-april-2025/perf_tracks.png differ
diff --git a/public/images/blog/react-labs-april-2025/perf_tracks.webp b/public/images/blog/react-labs-april-2025/perf_tracks.webp
new file mode 100644
index 000000000..88a7eb792
Binary files /dev/null and b/public/images/blog/react-labs-april-2025/perf_tracks.webp differ
diff --git a/public/images/blog/react-labs-april-2025/perf_tracks_dark.png b/public/images/blog/react-labs-april-2025/perf_tracks_dark.png
new file mode 100644
index 000000000..07513fe90
Binary files /dev/null and b/public/images/blog/react-labs-april-2025/perf_tracks_dark.png differ
diff --git a/public/images/blog/react-labs-april-2025/perf_tracks_dark.webp b/public/images/blog/react-labs-april-2025/perf_tracks_dark.webp
new file mode 100644
index 000000000..1a0521bf8
Binary files /dev/null and b/public/images/blog/react-labs-april-2025/perf_tracks_dark.webp differ
diff --git a/public/js/jsfiddle-integration-babel.js b/public/js/jsfiddle-integration-babel.js
index 006c79c8a..56059472f 100644
--- a/public/js/jsfiddle-integration-babel.js
+++ b/public/js/jsfiddle-integration-babel.js
@@ -4,12 +4,14 @@
 
 // Do not delete or move this file.
 // Many fiddles reference it so we have to keep it here.
-(function() {
+(function () {
   var tag = document.querySelector(
     'script[type="application/javascript;version=1.7"]'
   );
   if (!tag || tag.textContent.indexOf('window.onload=function(){') !== -1) {
-    alert('Bad JSFiddle configuration, please fork the original React JSFiddle');
+    alert(
+      'Bad JSFiddle configuration, please fork the original React JSFiddle'
+    );
   }
   tag.setAttribute('type', 'text/babel');
   tag.textContent = tag.textContent.replace(/^\/\/<!\[CDATA\[/, '');
diff --git a/public/js/jsfiddle-integration.js b/public/js/jsfiddle-integration.js
index fcf09e43f..2151435d4 100644
--- a/public/js/jsfiddle-integration.js
+++ b/public/js/jsfiddle-integration.js
@@ -4,12 +4,14 @@
 
 // Do not delete or move this file.
 // Many fiddles reference it so we have to keep it here.
-(function() {
+(function () {
   var tag = document.querySelector(
     'script[type="application/javascript;version=1.7"]'
   );
   if (!tag || tag.textContent.indexOf('window.onload=function(){') !== -1) {
-    alert('Bad JSFiddle configuration, please fork the original React JSFiddle');
+    alert(
+      'Bad JSFiddle configuration, please fork the original React JSFiddle'
+    );
   }
   tag.setAttribute('type', 'text/jsx;harmony=true');
   tag.textContent = tag.textContent.replace(/^\/\/<!\[CDATA\[/, '');
diff --git a/scripts/headingIDHelpers/walk.js b/scripts/headingIDHelpers/walk.js
index 721274e09..54cd500ca 100644
--- a/scripts/headingIDHelpers/walk.js
+++ b/scripts/headingIDHelpers/walk.js
@@ -2,10 +2,10 @@ const fs = require('fs');
 
 module.exports = function walk(dir) {
   let results = [];
-  /** 
+  /**
    * If the param is a directory we can return the file
    */
-  if(dir.includes('md')){
+  if (dir.includes('md')) {
     return [dir];
   }
   const list = fs.readdirSync(dir);
diff --git a/scripts/headingIdLinter.js b/scripts/headingIdLinter.js
index 037e4945f..6b8f75fc7 100644
--- a/scripts/headingIdLinter.js
+++ b/scripts/headingIdLinter.js
@@ -1,12 +1,12 @@
 const validateHeaderIds = require('./headingIDHelpers/validateHeadingIDs');
 const generateHeadingIds = require('./headingIDHelpers/generateHeadingIDs');
 
-/** 
+/**
  * yarn lint-heading-ids --> Checks all files and causes an error if heading ID is missing
  * yarn lint-heading-ids --fix --> Fixes all markdown file's heading IDs
  * yarn lint-heading-ids path/to/markdown.md --> Checks that particular file for missing heading ID (path can denote a directory or particular file)
  * yarn lint-heading-ids --fix path/to/markdown.md --> Fixes that particular file's markdown IDs (path can denote a directory or particular file)
-*/
+ */
 
 const markdownPaths = process.argv.slice(2);
 if (markdownPaths.includes('--fix')) {
diff --git a/src/components/Icon/IconExperimental.tsx b/src/components/Icon/IconExperimental.tsx
new file mode 100644
index 000000000..0bba612eb
--- /dev/null
+++ b/src/components/Icon/IconExperimental.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ */
+
+import {memo} from 'react';
+
+export const IconExperimental = memo<
+  JSX.IntrinsicElements['svg'] & {title?: string; size?: 's' | 'md'}
+>(function IconCanary(
+  {className, title, size} = {
+    className: undefined,
+    title: undefined,
+    size: 'md',
+  }
+) {
+  return (
+    <svg
+      className={className}
+      width={size === 's' ? '12px' : '20px'}
+      height={size === 's' ? '12px' : '20px'}
+      viewBox="0 0 20 20"
+      version="1.1"
+      xmlns="http://www.w3.org/2000/svg">
+      {title && <title>{title}</title>}
+      <g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
+        <g
+          id="noun-labs-1201738-(2)"
+          transform="translate(2, 0)"
+          fill="currentColor"
+          fillRule="nonzero">
+          <path
+            d="M10.2865804,5.55665262 L10.2865804,2.22331605 L10.8591544,2.22331605 C11.0103911,2.22244799 11.1551447,2.16342155 11.2617505,2.05914367 C11.3684534,1.95486857 11.4282767,1.81370176 11.4282767,1.66667106 L11.4282767,0.556642208 C11.4282767,0.40907262 11.3678934,0.26747526 11.2605218,0.16308627 C11.1531503,0.0587028348 11.0074938,0 10.8556998,0 L5.14338868,0 C4.9915947,0 4.84594391,0.0587028348 4.73856664,0.16308627 C4.63119507,0.267469704 4.57081178,0.40907262 4.57081178,0.556642208 L4.57081178,1.66667106 C4.57081178,1.81434899 4.63119507,1.95594912 4.73856664,2.06033811 C4.8459382,2.16472155 4.9915947,2.22331605 5.14338868,2.22331605 L5.71596273,2.22331605 L5.71596273,5.55665262 C5.71596273,8.38665538 2.97295619,9.88999017 0.651686904,15.5566623 C-0.0957823782,17.360053 -2.00560068,20 7.99951567,20 C18.004632,20 16.0948137,17.3600252 15.3507732,15.5566623 C13.0124432,9.88999017 10.2865804,8.38665538 10.2865804,5.55665262 Z M9.89570197,10.709991 C10.0921412,10.709991 10.2805515,10.7858383 10.4193876,10.9209301 C10.5583466,11.0559135 10.6363652,11.2390693 10.6363652,11.4300417 C10.6363652,11.6210141 10.5583466,11.8040698 10.4193876,11.9391533 C10.2805401,12.0741367 10.0921412,12.1499813 9.89570197,12.1499813 C9.6992627,12.1499813 9.51096673,12.074134 9.37201631,11.9391533 C9.23316875,11.8040615 9.15515307,11.6210141 9.15515307,11.4300417 C9.15515307,11.2390693 9.2331716,11.0559024 9.37201631,10.9209301 C9.57264221,10.7258996 9.61239426,10.709991 9.89570197,10.709991 Z M8.98919546,9.04212824 C9.09790709,9.14792278 9.15884755,9.29158681 9.1585213,9.44110085 C9.15829001,9.59073155 9.09678989,9.73407335 8.98763252,9.83954568 C8.87847514,9.945018 8.73069852,10.0039347 8.57678157,10.0033977 C8.42286747,10.0027392 8.27565088,9.94273467 8.16727355,9.83639845 C8.05900765,9.73006224 7.99873866,9.58628988 7.99963013,9.43664806 C8.00052304,9.28788403 8.0620221,9.14542556 8.17051087,9.04048101 C8.27911107,8.93555591 8.42599335,8.87663641 8.57913312,8.87663641 C8.73291864,8.87665585 8.88047525,8.93622535 8.98919546,9.04212824 Z M7.99965585,17.9999981 C4.91377349,17.9999981 3.29882839,17.7332867 2.51364277,17.4999976 C2.37780966,17.4476975 2.26954376,17.3439641 2.21396931,17.2125528 C2.15838628,17.0811499 2.16006066,16.9334692 2.21876871,16.8033858 C2.6144474,15.5921346 3.14916224,14.4280501 3.81316983,13.3333824 C5.980145,9.82337899 8.22941036,13.8867718 10.0980836,13.8867718 C11.9666996,13.8867718 11.4695868,12.1534924 12.1827971,13.3333824 C12.8511505,14.4269112 13.3916656,15.5896902 13.794259,16.8000524 C13.8533022,16.9322137 13.8537479,17.0822749 13.7952635,17.2147751 C13.7368889,17.3472613 13.6248314,17.4504531 13.4856467,17.5000531 C12.6833967,17.7332867 11.0855382,17.9999981 7.99965585,17.9999981 Z"
+            id="Shape"></path>
+        </g>
+      </g>
+    </svg>
+  );
+});
diff --git a/src/components/Layout/HomeContent.js b/src/components/Layout/HomeContent.js
index 72ab36884..83f5fa4f2 100644
--- a/src/components/Layout/HomeContent.js
+++ b/src/components/Layout/HomeContent.js
@@ -859,7 +859,8 @@ function ExampleLayout({
           </div>
           <div
             ref={contentRef}
-            className="relative mt-0 lg:-my-20 w-full p-2.5 xs:p-5 lg:p-10 flex grow justify-center">
+            className="relative mt-0 lg:-my-20 w-full p-2.5 xs:p-5 lg:p-10 flex grow justify-center"
+            dir="ltr">
             {right}
             <div
               className={cn(
diff --git a/src/components/Layout/Page.tsx b/src/components/Layout/Page.tsx
index 24d379589..ec3a6eba0 100644
--- a/src/components/Layout/Page.tsx
+++ b/src/components/Layout/Page.tsx
@@ -8,7 +8,7 @@ import {useRouter} from 'next/router';
 import {SidebarNav} from './SidebarNav';
 import {Footer} from './Footer';
 import {Toc} from './Toc';
-// import SocialBanner from '../SocialBanner';
+import SocialBanner from '../SocialBanner';
 import {DocsPageFooter} from 'components/DocsFooter';
 import {Seo} from 'components/Seo';
 import PageHeading from 'components/PageHeading';
@@ -31,7 +31,7 @@ interface PageProps {
   meta: {
     title?: string;
     titleForTitleTag?: string;
-    canary?: boolean;
+    version?: 'experimental' | 'canary';
     description?: string;
   };
   section: 'learn' | 'reference' | 'community' | 'blog' | 'home' | 'unknown';
@@ -53,7 +53,7 @@ export function Page({
     routeTree
   );
   const title = meta.title || route?.title || '';
-  const canary = meta.canary || false;
+  const version = meta.version;
   const description = meta.description || route?.description || '';
   const isHomePage = cleanedPath === '/';
   const isBlogIndex = cleanedPath === '/blog';
@@ -70,7 +70,7 @@ export function Page({
           )}>
           <PageHeading
             title={title}
-            canary={canary}
+            version={version}
             description={description}
             tags={route?.tags}
             breadcrumbs={breadcrumbs}
@@ -137,7 +137,7 @@ export function Page({
           />
         </Head>
       )}
-      {/*<SocialBanner />*/}
+      <SocialBanner />
       <TopNav
         section={section}
         routeTree={routeTree}
diff --git a/src/components/Layout/Sidebar/SidebarLink.tsx b/src/components/Layout/Sidebar/SidebarLink.tsx
index 4429989d2..aadb6cf4d 100644
--- a/src/components/Layout/Sidebar/SidebarLink.tsx
+++ b/src/components/Layout/Sidebar/SidebarLink.tsx
@@ -9,6 +9,7 @@ import * as React from 'react';
 import cn from 'classnames';
 import {IconNavArrow} from 'components/Icon/IconNavArrow';
 import {IconCanary} from 'components/Icon/IconCanary';
+import {IconExperimental} from 'components/Icon/IconExperimental';
 import Link from 'next/link';
 
 interface SidebarLinkProps {
@@ -16,7 +17,7 @@ interface SidebarLinkProps {
   selected?: boolean;
   title: string;
   level: number;
-  version?: 'canary' | 'major';
+  version?: 'canary' | 'major' | 'experimental';
   icon?: React.ReactNode;
   isExpanded?: boolean;
   hideArrow?: boolean;
@@ -84,7 +85,13 @@ export function SidebarLink({
         )}
         {version === 'canary' && (
           <IconCanary
-            title=" - This feature is available in the latest Canary"
+            title=" - This feature is available in the latest Canary version of React"
+            className="ms-1 text-gray-30 dark:text-gray-60 inline-block w-3.5 h-3.5 align-[-3px]"
+          />
+        )}
+        {version === 'experimental' && (
+          <IconExperimental
+            title=" - This feature is available in the latest Experimental version of React"
             className="ms-1 text-gray-30 dark:text-gray-60 inline-block w-3.5 h-3.5 align-[-3px]"
           />
         )}
diff --git a/src/components/MDX/ErrorDecoder.tsx b/src/components/MDX/ErrorDecoder.tsx
index b04fa9f79..a9b7455df 100644
--- a/src/components/MDX/ErrorDecoder.tsx
+++ b/src/components/MDX/ErrorDecoder.tsx
@@ -69,7 +69,7 @@ function parseQueryString(search: string): Array<string | undefined> {
 }
 
 export default function ErrorDecoder() {
-  const {errorMessage} = useErrorDecoderParams();
+  const {errorMessage, errorCode} = useErrorDecoderParams();
   /** error messages that contain %s require reading location.search */
   const hasParams = errorMessage?.includes('%s');
   const [message, setMessage] = useState<React.ReactNode | null>(() =>
@@ -82,23 +82,28 @@ export default function ErrorDecoder() {
     if (errorMessage == null || !hasParams) {
       return;
     }
+    const args = parseQueryString(window.location.search);
+    let message = errorMessage;
+    if (errorCode === '418') {
+      // Hydration errors have a %s for the diff, but we don't add that to the args for security reasons.
+      message = message.replace(/%s$/, '');
 
-    setMessage(
-      urlify(
-        replaceArgs(
-          errorMessage,
-          parseQueryString(window.location.search),
-          '[missing argument]'
-        )
-      )
-    );
+      // Before React 19.1, the error message didn't have an arg, and was always HTML.
+      if (args.length === 0) {
+        args.push('HTML');
+      } else if (args.length === 1 && args[0] === '') {
+        args[0] = 'HTML';
+      }
+    }
+
+    setMessage(urlify(replaceArgs(message, args, '[missing argument]')));
     setIsReady(true);
-  }, [hasParams, errorMessage]);
+  }, [errorCode, hasParams, errorMessage]);
 
   return (
     <code
       className={cn(
-        'block bg-red-100 text-red-600 py-4 px-6 mt-5 rounded-lg',
+        'whitespace-pre-line block bg-red-100 text-red-600 py-4 px-6 mt-5 rounded-lg',
         isReady ? 'opacity-100' : 'opacity-0'
       )}>
       <b>{message}</b>
diff --git a/src/components/MDX/ExpandableCallout.tsx b/src/components/MDX/ExpandableCallout.tsx
index 5f594063d..41d3d65d9 100644
--- a/src/components/MDX/ExpandableCallout.tsx
+++ b/src/components/MDX/ExpandableCallout.tsx
@@ -16,6 +16,7 @@ type CalloutVariants =
   | 'note'
   | 'wip'
   | 'canary'
+  | 'experimental'
   | 'major'
   | 'rsc';
 
@@ -51,6 +52,15 @@ const variantMap = {
     overlayGradient:
       'linear-gradient(rgba(245, 249, 248, 0), rgba(245, 249, 248, 1)',
   },
+  experimental: {
+    title: 'Experimental Feature',
+    Icon: IconCanary,
+    containerClasses:
+      'bg-green-5 dark:bg-green-60 dark:bg-opacity-20 text-primary dark:text-primary-dark text-lg',
+    textColor: 'text-green-60 dark:text-green-40',
+    overlayGradient:
+      'linear-gradient(rgba(245, 249, 248, 0), rgba(245, 249, 248, 1)',
+  },
   pitfall: {
     title: 'Pitfall',
     Icon: IconPitfall,
diff --git a/src/components/MDX/MDXComponents.tsx b/src/components/MDX/MDXComponents.tsx
index f24fac598..1da353f60 100644
--- a/src/components/MDX/MDXComponents.tsx
+++ b/src/components/MDX/MDXComponents.tsx
@@ -98,6 +98,10 @@ const Canary = ({children}: {children: React.ReactNode}) => (
   <ExpandableCallout type="canary">{children}</ExpandableCallout>
 );
 
+const Experimental = ({children}: {children: React.ReactNode}) => (
+  <ExpandableCallout type="experimental">{children}</ExpandableCallout>
+);
+
 const NextMajor = ({children}: {children: React.ReactNode}) => (
   <ExpandableCallout type="major">{children}</ExpandableCallout>
 );
@@ -120,6 +124,20 @@ const CanaryBadge = ({title}: {title: string}) => (
   </span>
 );
 
+const ExperimentalBadge = ({title}: {title: string}) => (
+  <span
+    title={title}
+    className={
+      'text-base font-display px-1 py-0.5 font-bold bg-gray-10 dark:bg-gray-60 text-gray-60 dark:text-gray-10 rounded'
+    }>
+    <IconCanary
+      size="s"
+      className={'inline me-1 mb-0.5 text-sm text-gray-60 dark:text-gray-10'}
+    />
+    Experimental only
+  </span>
+);
+
 const NextMajorBadge = ({title}: {title: string}) => (
   <span
     title={title}
@@ -508,6 +526,8 @@ export const MDXComponents = {
   MathI,
   Note,
   Canary,
+  Experimental,
+  ExperimentalBadge,
   CanaryBadge,
   NextMajor,
   NextMajorBadge,
diff --git a/src/components/MDX/Sandpack/template.ts b/src/components/MDX/Sandpack/template.ts
index 42f02f6a6..dd6fd12bd 100644
--- a/src/components/MDX/Sandpack/template.ts
+++ b/src/components/MDX/Sandpack/template.ts
@@ -1,7 +1,7 @@
 export const template = {
   '/src/index.js': {
     hidden: true,
-    code: `import React, { StrictMode } from "react";
+    code: `import { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
 import "./styles.css";
 
@@ -28,8 +28,8 @@ root.render(
           eject: 'react-scripts eject',
         },
         dependencies: {
-          react: '19.0.0-rc-3edc000d-20240926',
-          'react-dom': '19.0.0-rc-3edc000d-20240926',
+          react: '^19.1.0',
+          'react-dom': '^19.1.0',
           'react-scripts': '^5.0.0',
         },
       },
diff --git a/src/components/PageHeading.tsx b/src/components/PageHeading.tsx
index 6000c8e51..3f15afe95 100644
--- a/src/components/PageHeading.tsx
+++ b/src/components/PageHeading.tsx
@@ -8,10 +8,12 @@ import {H1} from './MDX/Heading';
 import type {RouteTag, RouteItem} from './Layout/getRouteMeta';
 import * as React from 'react';
 import {IconCanary} from './Icon/IconCanary';
+import {IconExperimental} from './Icon/IconExperimental';
 
 interface PageHeadingProps {
   title: string;
-  canary?: boolean;
+  version?: 'experimental' | 'canary';
+  experimental?: boolean;
   status?: string;
   description?: string;
   tags?: RouteTag[];
@@ -21,7 +23,7 @@ interface PageHeadingProps {
 function PageHeading({
   title,
   status,
-  canary,
+  version,
   tags = [],
   breadcrumbs,
 }: PageHeadingProps) {
@@ -31,9 +33,15 @@ function PageHeading({
         {breadcrumbs ? <Breadcrumbs breadcrumbs={breadcrumbs} /> : null}
         <H1 className="mt-0 text-primary dark:text-primary-dark -mx-.5 break-words">
           {title}
-          {canary && (
+          {version === 'canary' && (
             <IconCanary
-              title=" - This feature is available in the latest Canary"
+              title=" - This feature is available in the latest Canary version of React"
+              className="ms-4 mt-1 text-gray-50 dark:text-gray-40 inline-block w-6 h-6 align-[-1px]"
+            />
+          )}
+          {version === 'experimental' && (
+            <IconExperimental
+              title=" - This feature is available in the latest Experimental version of React"
               className="ms-4 mt-1 text-gray-50 dark:text-gray-40 inline-block w-6 h-6 align-[-1px]"
             />
           )}
diff --git a/src/components/Seo.tsx b/src/components/Seo.tsx
index 628085744..b8a8394b6 100644
--- a/src/components/Seo.tsx
+++ b/src/components/Seo.tsx
@@ -124,7 +124,14 @@ export const Seo = withRouter(
         )}
         <link
           rel="preload"
-          href="/fonts/Source-Code-Pro-Regular.woff2"
+          href="https://react.dev/fonts/Source-Code-Pro-Regular.woff2"
+          as="font"
+          type="font/woff2"
+          crossOrigin="anonymous"
+        />
+        <link
+          rel="preload"
+          href="https://react.dev/fonts/Source-Code-Pro-Bold.woff2"
           as="font"
           type="font/woff2"
           crossOrigin="anonymous"
diff --git a/src/components/SocialBanner.tsx b/src/components/SocialBanner.tsx
index 2db62c994..ae87d2050 100644
--- a/src/components/SocialBanner.tsx
+++ b/src/components/SocialBanner.tsx
@@ -7,7 +7,7 @@ import {useRef, useEffect} from 'react';
 import cn from 'classnames';
 import {ExternalLink} from './ExternalLink';
 
-const bannerText = 'Stream React Conf on May 15-16.';
+const bannerText = 'Join us for React Conf on Oct 7-8.';
 const bannerLink = 'https://conf.react.dev/';
 const bannerLinkText = 'Learn more.';
 
diff --git a/src/content/blog/2024/10/21/react-compiler-beta-release.md b/src/content/blog/2024/10/21/react-compiler-beta-release.md
index f5a870b22..58e6b24aa 100644
--- a/src/content/blog/2024/10/21/react-compiler-beta-release.md
+++ b/src/content/blog/2024/10/21/react-compiler-beta-release.md
@@ -10,6 +10,14 @@ October 21, 2024 by [Lauren Tan](https://twitter.com/potetotes).
 
 ---
 
+<Note>
+
+### React Compiler is now in RC! {/*react-compiler-is-now-in-rc*/}
+
+Please see the [RC blog post](/blog/2025/04/21/react-compiler-rc) for details.
+
+</Note>
+
 <Intro>
 
 The React team is excited to share new updates:
diff --git a/src/content/blog/2025/04/21/react-compiler-rc.md b/src/content/blog/2025/04/21/react-compiler-rc.md
new file mode 100644
index 000000000..ecbbb8747
--- /dev/null
+++ b/src/content/blog/2025/04/21/react-compiler-rc.md
@@ -0,0 +1,128 @@
+---
+title: "React Compiler RC"
+author: Lauren Tan and Mofei Zhang
+date: 2025/04/21
+description: We are releasing the compiler's first Release Candidate (RC) today.
+
+---
+
+April 21, 2025 by [Lauren Tan](https://x.com/potetotes) and [Mofei Zhang](https://x.com/zmofei).
+
+---
+
+<Intro>
+
+The React team is excited to share new updates:
+
+</Intro>
+
+1. We're publishing React Compiler RC today, in preparation of the compiler's stable release.
+2. We're merging `eslint-plugin-react-compiler` into `eslint-plugin-react-hooks`.
+3. We've added support for swc and are working with oxc to support Babel-free builds.
+
+---
+
+[React Compiler](https://react.dev/learn/react-compiler) is a build-time tool that optimizes your React app through automatic memoization. Last year, we published React Compiler’s [first beta](https://react.dev/blog/2024/10/21/react-compiler-beta-release) and received lots of great feedback and contributions. We’re excited about the wins we’ve seen from folks adopting the compiler (see case studies from [Sanity Studio](https://github.com/reactwg/react-compiler/discussions/33) and [Wakelet](https://github.com/reactwg/react-compiler/discussions/52)) and are working towards a stable release.
+
+We are releasing the compiler's first Release Candidate (RC) today. The RC is intended to be a stable and near-final version of the compiler, and safe to try out in production.
+
+## Use React Compiler RC today {/*use-react-compiler-rc-today*/}
+To install the RC:
+
+npm
+<TerminalBlock>
+{`npm install --save-dev --save-exact babel-plugin-react-compiler@rc`}
+</TerminalBlock>
+
+pnpm
+<TerminalBlock>
+{`pnpm add --save-dev --save-exact babel-plugin-react-compiler@rc`}
+</TerminalBlock>
+
+yarn
+<TerminalBlock>
+{`yarn add --dev --exact babel-plugin-react-compiler@rc`}
+</TerminalBlock>
+
+As part of the RC, we've been making React Compiler easier to add to your projects and added optimizations to how the compiler generates memoization. React Complier now supports optional chains and array indices as dependencies. We're exploring how to infer even more dependencies like equality checks and string interpolation. These improvements ultimately result in fewer re-renders and more responsive UIs.
+
+We have also heard from the community that the ref-in-render validation sometimes has false positives. Since as a general philosophy we want you to be able to fully trust in the compiler's error messages and hints, we are turning it off by default for now. We will keep working to improve this validation, and we will re-enable it in a follow up release.
+
+You can find more details on using the Compiler in [our docs](https://react.dev/learn/react-compiler).
+
+## Feedback {/*feedback*/}
+During the RC period, we encourage all React users to try the compiler and provide feedback in the React repo. Please [open an issue](https://github.com/facebook/react/issues) if you encounter any bugs or unexpected behavior. If you have a general question or suggestion, please post them in the [React Compiler Working Group](https://github.com/reactwg/react-compiler/discussions).
+
+## Backwards Compatibility {/*backwards-compatibility*/}
+As noted in the Beta announcement, React Compiler is compatible with React 17 and up. If you are not yet on React 19, you can use React Compiler by specifying a minimum target in your compiler config, and adding `react-compiler-runtime` as a dependency. You can find docs on this [here](https://react.dev/learn/react-compiler#using-react-compiler-with-react-17-or-18).
+
+## Migrating from eslint-plugin-react-compiler to eslint-plugin-react-hooks {/*migrating-from-eslint-plugin-react-compiler-to-eslint-plugin-react-hooks*/}
+If you have already installed eslint-plugin-react-compiler, you can now remove it and use `eslint-plugin-react-hooks@6.0.0-rc.1`. Many thanks to [@michaelfaith](https://bsky.app/profile/michael.faith) for contributing to this improvement!
+
+To install:
+
+npm
+<TerminalBlock>
+{`npm install --save-dev eslint-plugin-react-hooks@6.0.0-rc.1`}
+</TerminalBlock>
+
+pnpm
+<TerminalBlock>
+{`pnpm add --save-dev eslint-plugin-react-hooks@6.0.0-rc.1`}
+</TerminalBlock>
+
+yarn
+<TerminalBlock>
+{`yarn add --dev eslint-plugin-react-hooks@6.0.0-rc.1`}
+</TerminalBlock>
+
+```js
+// eslint.config.js
+import * as reactHooks from 'eslint-plugin-react-hooks';
+
+export default [
+  // Flat Config (eslint 9+)
+  reactHooks.configs.recommended,
+
+  // Legacy Config
+  reactHooks.configs['recommended-latest']
+];
+```
+
+To enable the React Compiler rule, add `'react-hooks/react-compiler': 'error'` to your ESLint configuration.
+
+The linter does not require the compiler to be installed, so there's no risk in upgrading eslint-plugin-react-hooks. We recommend everyone upgrade today.
+
+## swc support (experimental) {/*swc-support-experimental*/}
+React Compiler can be installed across [several build tools](/learn/react-compiler#installation) such as Babel, Vite, and Rsbuild.
+
+In addition to those tools, we have been collaborating with Kang Dongyoon ([@kdy1dev](https://x.com/kdy1dev)) from the [swc](https://swc.rs/) team on adding additional support for React Compiler as an swc plugin. While this work isn't done, Next.js build performance should now be considerably faster when the [React Compiler is enabled in your Next.js app](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactCompiler).
+
+We recommend using Next.js [15.3.1](https://github.com/vercel/next.js/releases/tag/v15.3.1) or greater to get the best build performance.
+
+Vite users can continue to use [vite-plugin-react](https://github.com/vitejs/vite-plugin-react) to enable the compiler, by adding it as a [Babel plugin](https://react.dev/learn/react-compiler#usage-with-vite). We are also working with the [oxc](https://oxc.rs/) team to [add support for the compiler](https://github.com/oxc-project/oxc/issues/10048). Once [rolldown](https://github.com/rolldown/rolldown) is officially released and supported in Vite and oxc support is added for React Compiler, we'll update the docs with information on how to migrate.
+
+## Upgrading React Compiler {/*upgrading-react-compiler*/}
+React Compiler works best when the auto-memoization applied is strictly for performance. Future versions of the compiler may change how memoization is applied, for example it could become more granular and precise.
+
+However, because product code may sometimes break the [rules of React](https://react.dev/reference/rules) in ways that aren't always statically detectable in JavaScript, changing memoization can occasionally have unexpected results. For example, a previously memoized value might be used as a dependency for a useEffect somewhere in the component tree. Changing how or whether this value is memoized can cause over or under-firing of that useEffect. While we encourage [useEffect only for synchronization](https://react.dev/learn/synchronizing-with-effects), your codebase may have useEffects that cover other use-cases such as effects that needs to only run in response to specific values changing.
+
+In other words, changing memoization may under rare circumstances cause unexpected behavior. For this reason, we recommend following the Rules of React and employing continuous end-to-end testing of your app so you can upgrade the compiler with confidence and identify any rules of React violations that might cause issues.
+
+If you don't have good test coverage, we recommend pinning the compiler to an exact version (eg `19.1.0`) rather than a SemVer range (eg `^19.1.0`). You can do this by passing the `--save-exact` (npm/pnpm) or `--exact` flags (yarn) when upgrading the compiler. You should then do any upgrades of the compiler manually, taking care to check that your app still works as expected.
+
+## Roadmap to Stable {/*roadmap-to-stable*/}
+*This is not a final roadmap, and is subject to change.*
+
+After a period of final feedback from the community on the RC, we plan on a Stable Release for the compiler.
+
+* ✅ Experimental: Released at React Conf 2024, primarily for feedback from application developers.
+* ✅ Public Beta: Available today, for feedback from library authors.
+* ✅ Release Candidate (RC): React Compiler works for the majority of rule-following apps and libraries without issue.
+* General Availability: After final feedback period from the community.
+
+Post-Stable, we plan to add more compiler optimizations and improvements. This includes both continual improvements to automatic memoization, and new optimizations altogether, with minimal to no change of product code. Each upgrade will continue to improve performance and add better handling of diverse JavaScript and React patterns.
+
+---
+
+Thanks to [Joe Savona](https://x.com/en_JS), [Jason Bonta](https://x.com/someextent), [Jimmy Lai](https://x.com/feedthejim), and [Kang Dongyoon](https://x.com/kdy1dev) (@kdy1dev) for reviewing and editing this post.
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
new file mode 100644
index 000000000..e4bb25a4a
--- /dev/null
+++ b/src/content/blog/2025/04/23/react-labs-view-transitions-activity-and-more.md
@@ -0,0 +1,14358 @@
+---
+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.
+---
+
+April 23, 2025 by [Ricky Hanlon](https://twitter.com/rickhanlonii)
+
+---
+
+<Intro>
+
+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.
+
+</Intro>
+
+
+<Note>
+
+React Conf 2025 is scheduled for October 7–8 in Henderson, Nevada! 
+
+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).
+
+For more info on tickets, free streaming, sponsoring, and more, see [the React Conf website](https://conf.react.dev).
+
+</Note>
+
+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)
+- [Fragment Refs](#fragment-refs)
+- [Concurrent Stores](#concurrent-stores)
+
+---
+
+# 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.
+
+You can try them by upgrading React packages to the most recent experimental version:
+
+- `react@experimental`
+- `react-dom@experimental`
+
+Read on to learn how to use these features in your app, or check out the newly published docs:
+
+- [`<ViewTransition>`](/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.
+- [`<Activity>`](/reference/react/Activity): A component that lets you hide and show parts of the UI.
+
+## 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.
+
+To opt-in to animating an element, wrap it in the new `<ViewTransition>` component:
+
+```js
+// "what" to animate.
+<ViewTransition>
+  <div>animate me</div>
+</ViewTransition>
+```
+
+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
+<Suspense fallback={<Fallback />}>
+  <div>Loading...</div>
+</Suspense>
+```
+
+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:
+
+```
+// "how" to animate.
+::view-transition-old(*) {
+  animation: 300ms ease-out fade-out;
+}
+::view-transition-new(*) {
+  animation: 300ms ease-in fade-in;
+}
+```
+
+When the DOM updates due to an animation trigger&mdash;like `startTransition`, `useDeferredValue`, or a `Suspense` fallback switching to content&mdash;React will use [declarative heuristics](/reference/react/ViewTransition#viewtransition) to automatically determine which `<ViewTransition>` components to activate for the animation. The browser will then run the animation that's defined in CSS.
+
+If you're familiar with the browser's View Transition API and want to know how React supports it, check out [How does `<ViewTransition>` Work](/reference/react/ViewTransition#how-does-viewtransition-work) in the docs. 
+
+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.
+
+<Sandpack>
+
+```js src/App.js active
+import TalkDetails from './Details'; import Home from './Home'; import {useRouter} from './router';
+
+export default function App() {
+  const {url} = useRouter();
+
+  // 🚩This version doesn't include any animations yet
+  return url === '/' ? <Home /> : <TalkDetails />;
+}
+```
+
+```js src/Details.js
+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 (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <Suspense fallback={<VideoInfoFallback />}>
+          <VideoInfo id={video.id} />
+        </Suspense>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Home.js
+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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```js src/Layout.js
+import { useIsNavPending } from "./router";
+
+export default function Page({ heading, children }) {
+  const isPending = useIsNavPending();
+  return (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {heading}
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+
+      <div className="bottom">
+        <div className="content">{children}</div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js src/LikeButton.js
+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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js
+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 (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    >
+      {children}
+    </div>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js
+import {
+  useState,
+  createContext,
+  use,
+  useTransition,
+  useLayoutEffect,
+  useEffect,
+} from "react";
+
+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 (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+```
+
+```css src/styles.css
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+<Note>
+
+#### View Transitions do not replace CSS and JS driven animations {/*view-transitions-do-not-replace-css-and-js-driven-animations*/}
+
+View Transitions are meant to be used for UI transitions such as navigation, expanding, opening, or re-ordering. They are not meant to replace all the animations in your app.
+
+In our example app above, notice that there are already animations when you click the "like" button and in the Suspense fallback glimmer. These are good use cases for CSS animations because they are animating a specific element.
+
+</Note>
+
+### Animating navigations {/*animating-navigations*/}
+
+Our app includes a Suspense-enabled router, with [page transitions already marked as Transitions](/reference/react/useTransition#building-a-suspense-enabled-router), which means navigations are performed with `startTransition`:
+
+```js
+function navigate(url) {
+  startTransition(() => {
+    go(url);
+  });
+}
+```
+
+`startTransition` is a View Transition trigger, so we can add `<ViewTransition>` to animate between pages:
+
+```js
+// "what" to animate
+<ViewTransition key={url}>
+  {url === '/' ? <Home /> : <TalkDetails />}
+</ViewTransition>
+```
+
+When the `url` changes, the `<ViewTransition>` and new route are rendered. Since the `<ViewTransition>` was updated inside of `startTransition`, the `<ViewTransition>` is activated for an animation.
+
+
+By default, View Transitions include the browser default cross-fade animation. Adding this to our example, we now have a cross-fade whenever we navigate between pages: 
+
+<Sandpack>
+
+```js src/App.js active
+import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router';
+
+export default function App() {
+  const {url} = useRouter();
+  
+  // Use ViewTransition to animate between pages.
+  // No additional CSS needed by default.
+  return (
+    <ViewTransition>
+      {url === '/' ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```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 (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <Suspense fallback={<VideoInfoFallback />}>
+          <VideoInfo id={video.id} />
+        </Suspense>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```js src/Layout.js
+import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router";
+
+export default function Page({ heading, children }) {
+  const isPending = useIsNavPending();
+  
+  return (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {heading}
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```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 (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    >
+      {children}
+    </div>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js
+import {useState, createContext,use,useTransition,useLayoutEffect,useEffect} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  
+  function navigate(url) {
+    // Update router state in transition.
+    startTransition(() => {
+      go(url);
+    });
+  }
+  
+  
+  
+  
+  const [routerState, setRouterState] = useState({
+    pendingNav: () => {},
+    url: document.location.pathname,
+  });
+  
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+
+  function navigateBack(url) {
+    startTransition(() => {
+      go(url);
+    });
+  }
+
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+Since our router already updates the route using `startTransition`, this one line change to add `<ViewTransition>` activates with the default cross-fade animation. 
+
+If you're curious how this works, see the docs for [How does `<ViewTransition>` work?](/reference/react/ViewTransition#how-does-viewtransition-work)
+
+<Note>
+
+#### Opting out of `<ViewTransition>` animations {/*opting-out-of-viewtransition-animations*/}
+
+In this example, we're wrapping the root of the app in `<ViewTransition>` for simplicity, but this means that all transitions in the app will be animated, which can lead to unexpected animations. 
+
+To fix, we're wrapping route children with `"none"` so each page can control its own animation:
+
+```js
+// Layout.js
+<ViewTransition default="none">
+  {children}
+</ViewTransition>
+```
+
+In practice, navigations should be done via "enter" and "exit" props, or by using Transition Types. 
+
+</Note>
+
+### Customizing animations {/*customizing-animations*/}
+
+By default, `<ViewTransition>` includes the default cross-fade from the browser.
+
+To customize animations, you can provide props to the `<ViewTransition>` component to specify which animations to use, based on [how the `<ViewTransition>` activates](/reference/react/ViewTransition#props).
+
+For example, we can slow down the `default` cross fade animation:
+
+```js
+<ViewTransition default="slow-fade">
+  <Home />
+</ViewTransition>
+```
+
+And define `slow-fade` in CSS using [view transition classes](/reference/react/ViewTransition#view-transition-classes):
+
+```css
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+Now, the cross fade is slower:
+
+<Sandpack>
+
+```js src/App.js active
+import { unstable_ViewTransition as ViewTransition } from "react";
+import Details from "./Details";
+import Home from "./Home";
+import { useRouter } from "./router";
+
+export default function App() {
+  const { url } = useRouter();
+
+  // Define a default animation of .slow-fade.
+  // See animations.css for the animation definiton.
+  return (
+    <ViewTransition default="slow-fade">
+      {url === '/' ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```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 (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <Suspense fallback={<VideoInfoFallback />}>
+          <VideoInfo id={video.id} />
+        </Suspense>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {heading}
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```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 (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    >
+      {children}
+    </div>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```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 (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+```
+
+```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(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+See [Styling View Transitions](/reference/react/ViewTransition#styling-view-transitions) for a full guide on styling `<ViewTransition>`.
+
+### 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 `<ViewTransition>`:
+
+```js
+<ViewTransition name={`video-${video.id}`}>
+  <Thumbnail video={video} />
+</ViewTransition>
+```
+
+Now the video thumbnail animates between the two pages:
+
+<Sandpack>
+
+```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 (
+    <ViewTransition default="slow-fade">
+      {url === "/" ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```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 (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <Suspense fallback={<VideoInfoFallback />}>
+          <VideoInfo id={video.id} />
+        </Suspense>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {heading}
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js active
+import { useState, unstable_ViewTransition as ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```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 (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* No additional animations needed */
+
+
+
+
+
+
+
+
+
+/* Previously defined animations below */
+
+
+
+
+
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+By default, React automatically generates a unique `name` for each element activated for a transition (see [How does `<ViewTransition>` work](/reference/react/ViewTransition#how-does-viewtransition-work)). When React sees a transition where a `<ViewTransition>` with a `name` is removed and a new `<ViewTransition>` with the same `name` is added, it will activate a shared element transition.
+
+For more info, see the docs for [Animating a Shared Element](/reference/react/ViewTransition#animating-a-shared-element).
+
+### Animating based on cause {/*animating-based-on-cause*/}
+
+Sometimes, you may want elements to animate differently based on how it was triggered. For this use case, we've added a new API called `addTransitionType` to specify the cause of a transition:
+
+```js {4,11}
+function navigate(url) {
+  startTransition(() => {
+    // Transition type for the cause "nav forward"
+    addTransitionType('nav-forward');
+    go(url);
+  });
+}
+function navigateBack(url) {
+  startTransition(() => {
+    // Transition type for the cause "nav backward"
+    addTransitionType('nav-back');
+    go(url);
+  });
+}
+```
+
+With transition types, you can provide custom animations via props to `<ViewTransition>`. Let's add a shared element transition to the header for "6 Videos" and "Back":
+
+```js {4,5}
+<ViewTransition
+  name="nav"
+  share={{
+    'nav-forward': 'slide-forward',
+    'nav-back': 'slide-back',
+  }}>
+  {heading}
+</ViewTransition>
+```
+
+Here we pass a `share` prop to define how to animate based on the transition type. When the share transition activates from `nav-forward`, the view transition class `slide-forward` is applied. When it's from `nav-back`, the `slide-back` animation is activated. Let's define these animations in CSS:
+
+```css
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: ...
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: ...
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: ...
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: ...
+}
+```
+
+Now we can animate the header along with thumbnail based on navigation type:
+
+<Sandpack>
+
+```js src/App.js hidden
+import { unstable_ViewTransition as ViewTransition } from "react";
+import Details from "./Details";
+import Home from "./Home";
+import { useRouter } from "./router";
+
+export default function App() {
+  const { url } = useRouter();
+
+  // Keeping our default slow-fade.
+  return (
+    <ViewTransition default="slow-fade">
+      {url === "/" ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```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 (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <Suspense fallback={<VideoInfoFallback />}>
+          <VideoInfo id={video.id} />
+        </Suspense>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```js src/Layout.js active
+import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router";
+
+export default function Page({ heading, children }) {
+  const isPending = useIsNavPending();
+  return (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js hidden
+import { useState, unstable_ViewTransition as ViewTransition } from "react";
+import LikeButton from "./LikeButton";
+import { useRouter } from "./router";
+import { PauseIcon, PlayIcon } from "./Icons";
+import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* New keyframes to support our animations above. */
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+/* Previously defined animations. */
+
+/* Default .slow-fade. */
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+### Animating Suspense Boundaries {/*animating-suspense-boundaries*/}
+
+Suspense will also activate View Transitions. 
+
+To animate the fallback to content, we can wrap `Suspense` with `<ViewTranstion>`:
+
+```js
+<ViewTransition>
+  <Suspense fallback={<VideoInfoFallback />}>
+    <VideoInfo />
+  </Suspense>
+</ViewTransition>
+```
+
+By adding this, the fallback will cross-fade into the content. Click a video and see the video info animate in:
+
+<Sandpack>
+
+```js src/App.js hidden
+import { unstable_ViewTransition as ViewTransition } from "react";
+import Details from "./Details";
+import Home from "./Home";
+import { useRouter } from "./router";
+
+export default function App() {
+  const { url } = useRouter();
+
+  // Default slow-fade animation.
+  return (
+    <ViewTransition default="slow-fade">
+      {url === "/" ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```js src/Details.js active
+import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons";
+
+function VideoDetails({ id }) {
+  // Cross-fade the fallback to content.
+  return (
+    <ViewTransition default="slow-fade">
+      <Suspense fallback={<VideoInfoFallback />}>
+          <VideoInfo id={id} />
+      </Suspense>
+    </ViewTransition>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <div>
+      <div className="fit fallback title"></div>
+      <div className="fit fallback description"></div>
+    </div>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <VideoDetails id={video.id} />
+      </div>
+    </Layout>
+  );
+}
+
+function VideoInfo({ id }) {
+  const details = use(fetchVideoDetails(id));
+  return (
+    <div>
+      <p className="fit info-title">{details.title}</p>
+      <p className="fit info-description">{details.description}</p>
+    </div>
+  );
+}
+```
+
+```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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js hidden
+import { useState, unstable_ViewTransition as ViewTransition } from "react";
+import LikeButton from "./LikeButton";
+import { useRouter } from "./router";
+import { PauseIcon, PlayIcon } from "./Icons";
+import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js hidden
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* Slide the fallback down */
+::view-transition-old(.slide-down) {
+    animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down;
+}
+
+/* Slide the content up */
+::view-transition-new(.slide-up) {
+    animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up;
+}
+
+/* Define the new keyframes */
+@keyframes slide-up {
+    from {
+        transform: translateY(10px);
+    }
+    to {
+        transform: translateY(0);
+    }
+}
+
+@keyframes slide-down {
+    from {
+        transform: translateY(0);
+    }
+    to {
+        transform: translateY(10px);
+    }
+}
+
+/* Previously defined animations below */
+
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* Keyframes to support our animations above. */
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+/* Default .slow-fade. */
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+We can also provide custom animations using an `exit` on the fallback, and `enter` on the content:
+
+```js {3,8}
+<Suspense
+  fallback={
+    <ViewTransition exit="slide-down">
+      <VideoInfoFallback />
+    </ViewTransition>
+  }
+>
+  <ViewTransition enter="slide-up">
+    <VideoInfo id={id} />
+  </ViewTransition>
+</Suspense>
+```
+
+Here's how we'll define `slide-down` and `slide-up` with CSS:
+
+```css {1, 6}
+::view-transition-old(.slide-down) { 
+  /* Slide the fallback down */
+  animation: ...;
+}
+
+::view-transition-new(.slide-up) {
+  /* Slide the content up */
+  animation: ...;
+}
+```
+
+Now, the Suspense content replaces the fallback with a sliding animation:
+
+<Sandpack>
+
+```js src/App.js hidden
+import { unstable_ViewTransition as ViewTransition } from "react";
+import Details from "./Details";
+import Home from "./Home";
+import { useRouter } from "./router";
+
+export default function App() {
+  const { url } = useRouter();
+
+  // Default slow-fade animation.
+  return (
+    <ViewTransition default="slow-fade">
+      {url === "/" ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```js src/Details.js active
+import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons";
+
+function VideoDetails({ id }) {
+  return (
+    <Suspense
+      fallback={
+        // Animate the fallback down.
+        <ViewTransition exit="slide-down">
+          <VideoInfoFallback />
+        </ViewTransition>
+      }
+    >
+      {/* Animate the content up */}
+      <ViewTransition enter="slide-up">
+        <VideoInfo id={id} />
+      </ViewTransition>
+    </Suspense>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <VideoDetails id={video.id} />
+      </div>
+    </Layout>
+  );
+}
+
+function VideoInfo({ id }) {
+  const details = use(fetchVideoDetails(id));
+  return (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+```
+
+```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 (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+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 (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <div className="video-list">
+        {foundVideos.length === 0 && (
+          <div className="no-results">No results</div>
+        )}
+        <div className="videos">
+          {foundVideos.map((video) => (
+            <Video key={video.id} video={video} />
+          ))}
+        </div>
+      </div>
+    </Layout>
+  );
+}
+
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js hidden
+import { useState, unstable_ViewTransition as ViewTransition } from "react";
+import LikeButton from "./LikeButton";
+import { useRouter } from "./router";
+import { PauseIcon, PlayIcon } from "./Icons";
+import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js hidden
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* Slide the fallback down */
+::view-transition-old(.slide-down) {
+    animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down;
+}
+
+/* Slide the content up */
+::view-transition-new(.slide-up) {
+    animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up;
+}
+
+/* Define the new keyframes */
+@keyframes slide-up {
+    from {
+        transform: translateY(10px);
+    }
+    to {
+        transform: translateY(0);
+    }
+}
+
+@keyframes slide-down {
+    from {
+        transform: translateY(0);
+    }
+    to {
+        transform: translateY(10px);
+    }
+}
+
+/* Previously defined animations below */
+
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* Keyframes to support our animations above. */
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+/* Default .slow-fade. */
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+
+### Animating Lists {/*animating-lists*/}
+
+You can also use `<ViewTransition>` to animate lists of items as they re-order, like in a searchable list of items:
+
+```js {3,5}
+<div className="videos">
+  {filteredVideos.map((video) => (
+    <ViewTransition key={video.id}>
+      <Video video={video} />
+    </ViewTransition>
+  ))}
+</div>
+```
+
+To activate the ViewTransition, we can use `useDeferredValue`:
+
+```js {2}
+const [searchText, setSearchText] = useState('');
+const deferredSearchText = useDeferredValue(searchText);
+const filteredVideos = filterVideos(videos, deferredSearchText);
+```
+
+Now the items animate as you type in the search bar:
+
+<Sandpack>
+
+```js src/App.js hidden
+import { unstable_ViewTransition as ViewTransition } from "react";
+import Details from "./Details";
+import Home from "./Home";
+import { useRouter } from "./router";
+
+export default function App() {
+  const { url } = useRouter();
+
+  // Default slow-fade animation.
+  return (
+    <ViewTransition default="slow-fade">
+      {url === "/" ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```js src/Details.js hidden
+import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react";
+import { fetchVideo, fetchVideoDetails } from "./data";
+import { Thumbnail, VideoControls } from "./Videos";
+import { useRouter } from "./router";
+import Layout from "./Layout";
+import { ChevronLeft } from "./Icons";
+
+function VideoDetails({id}) {
+  // Animate from Suspense fallback to content
+  return (
+    <Suspense
+      fallback={
+        // Animate the fallback down.
+        <ViewTransition exit="slide-down">
+          <VideoInfoFallback />
+        </ViewTransition>
+      }
+    >
+      {/* Animate the content up */}
+      <ViewTransition enter="slide-up">
+        <VideoInfo id={id} />
+      </ViewTransition>
+    </Suspense>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <VideoDetails id={video.id} />
+      </div>
+    </Layout>
+  );
+}
+
+function VideoInfo({ id }) {
+  const details = use(fetchVideoDetails(id));
+  return (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+```
+
+```js src/Home.js
+import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons";
+
+function SearchList({searchText, videos}) {
+  // Activate with useDeferredValue ("when") 
+  const deferredSearchText = useDeferredValue(searchText);
+  const filteredVideos = filterVideos(videos, deferredSearchText);
+  return (
+    <div className="video-list">
+      <div className="videos">
+        {filteredVideos.map((video) => (
+          // Animate each item in list ("what") 
+          <ViewTransition key={video.id}>
+            <Video video={video} />
+          </ViewTransition>
+        ))}
+      </div>
+      {filteredVideos.length === 0 && (
+        <div className="no-results">No results</div>
+      )}
+    </div>
+  );
+}
+
+export default function Home() {
+  const videos = use(fetchVideos());
+  const count = videos.length;
+  const [searchText, setSearchText] = useState('');
+  
+  return (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <SearchList videos={videos} searchText={searchText} />
+    </Layout>
+  );
+}
+
+function SearchInput({ value, onChange }) {
+  const id = useId();
+  return (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+function filterVideos(videos, query) {
+  const keywords = query
+    .toLowerCase()
+    .split(" ")
+    .filter((s) => s !== "");
+  if (keywords.length === 0) {
+    return videos;
+  }
+  return videos.filter((video) => {
+    const words = (video.title + " " + video.description)
+      .toLowerCase()
+      .split(" ");
+    return keywords.every((kw) => words.some((w) => w.includes(kw)));
+  });
+}
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js hidden
+import { useState, unstable_ViewTransition as ViewTransition } from "react";
+import LikeButton from "./LikeButton";
+import { useRouter } from "./router";
+import { PauseIcon, PlayIcon } from "./Icons";
+import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js hidden
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* No additional animations needed */
+
+
+
+
+
+
+
+
+
+/* Previously defined animations below */
+
+
+
+
+
+
+/* Slide animation for Suspense */
+::view-transition-old(.slide-down) {
+    animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down;
+}
+
+::view-transition-new(.slide-up) {
+    animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up;
+}
+
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* Keyframes to support our animations above. */
+@keyframes slide-up {
+    from {
+        transform: translateY(10px);
+    }
+    to {
+        transform: translateY(0);
+    }
+}
+
+@keyframes slide-down {
+    from {
+        transform: translateY(0);
+    }
+    to {
+        transform: translateY(10px);
+    }
+}
+
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+
+/* Default .slow-fade. */
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+### Final result {/*final-result*/}
+
+By adding a few `<ViewTransition>` components and a few lines of CSS, we were able to add all the animations above into the final result.
+
+We're excited about View Transitions and think they will level up the apps you're able to build. They're ready to start trying today in the experimental channel of React releases.
+
+Let's remove the slow fade, and take a look at the final result:
+
+<Sandpack>
+
+```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();
+
+  // Animate with a cross fade between pages.
+  return (
+    <ViewTransition key={url}>
+      {url === '/' ? <Home /> : <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```js src/Details.js
+import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons";
+
+function VideoDetails({id}) {
+  // Animate from Suspense fallback to content
+  return (
+    <Suspense
+      fallback={
+        // Animate the fallback down.
+        <ViewTransition exit="slide-down">
+          <VideoInfoFallback />
+        </ViewTransition>
+      }
+    >
+      {/* Animate the content up */}
+      <ViewTransition enter="slide-up">
+        <VideoInfo id={id} />
+      </ViewTransition>
+    </Suspense>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <VideoDetails id={video.id} />
+      </div>
+    </Layout>
+  );
+}
+
+function VideoInfo({ id }) {
+  const details = use(fetchVideoDetails(id));
+  return (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+```
+
+```js src/Home.js
+import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons";
+
+function SearchList({searchText, videos}) {
+  // Activate with useDeferredValue ("when") 
+  const deferredSearchText = useDeferredValue(searchText);
+  const filteredVideos = filterVideos(videos, deferredSearchText);
+  return (
+    <div className="video-list">
+      <div className="videos">
+        {filteredVideos.map((video) => (
+          // Animate each item in list ("what") 
+          <ViewTransition key={video.id}>
+            <Video video={video} />
+          </ViewTransition>
+        ))}
+      </div>
+      {filteredVideos.length === 0 && (
+        <div className="no-results">No results</div>
+      )}
+    </div>
+  );
+}
+
+export default function Home() {
+  const videos = use(fetchVideos());
+  const count = videos.length;
+  const [searchText, setSearchText] = useState('');
+  
+  return (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <SearchList videos={videos} searchText={searchText} />
+    </Layout>
+  );
+}
+
+function SearchInput({ value, onChange }) {
+  const id = useId();
+  return (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+function filterVideos(videos, query) {
+  const keywords = query
+    .toLowerCase()
+    .split(" ")
+    .filter((s) => s !== "");
+  if (keywords.length === 0) {
+    return videos;
+  }
+  return videos.filter((video) => {
+    const words = (video.title + " " + video.description)
+      .toLowerCase()
+      .split(" ");
+    return keywords.every((kw) => words.some((w) => w.includes(kw)));
+  });
+}
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```js src/Layout.js
+import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router";
+
+export default function Page({ heading, children }) {
+  const isPending = useIsNavPending();
+  return (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```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 (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+  
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* Slide animations for Suspense the fallback down */
+::view-transition-old(.slide-down) {
+    animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down;
+}
+
+::view-transition-new(.slide-up) {
+    animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up;
+}
+
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* Keyframes to support our animations above. */
+@keyframes slide-up {
+    from {
+        transform: translateY(10px);
+    }
+    to {
+        transform: translateY(0);
+    }
+}
+
+@keyframes slide-down {
+    from {
+        transform: translateY(0);
+    }
+    to {
+        transform: translateY(10px);
+    }
+}
+
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+```
+
+```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(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+If you're curious to know more about how they work, check out [How Does `<ViewTransition>` Work](/reference/react/ViewTransition#how-does-viewtransition-work) in the docs.
+
+_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!)._
+
+---
+
+## 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.
+
+We're now ready to share the API and how it works, so you can start testing it in experimental React versions.
+
+`<Activity>` is a new component to hide and show parts of the UI:
+
+```js [[1, 1, "'visible'"], [2, 1, "'hidden'"]]
+<Activity mode={isVisible ? 'visible' : 'hidden'}>
+  <Page />
+</Activity>
+```
+
+When an Activity is <CodeStep step={1}>visible</CodeStep> it's rendered as normal. When an Activity is <CodeStep step={2}>hidden</CodeStep> 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.
+
+Let's look at some examples improving the View Transition examples above.
+
+<Note>
+
+**Effects don’t mount when an Activity is hidden.**
+
+When an `<Activity>` is `hidden`, Effects are unmounted. Conceptually, the component is unmounted, but React saves the state for later.
+
+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 [`<StrictMode>`](/reference/react/StrictMode) which will eagerly perform Activity unmounts and mounts to catch any unexpected side effects.
+
+</Note>
+
+### Restoring state with 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() {
+  const { url } = useRouter();
+  
+  return (
+    <>
+      {url === '/' && <Home />}
+      {url !== '/' && <Details />}
+    </>
+  );
+}
+```
+
+However, this means if the user goes back to the old page, all of the previous state is lost. For example, if the `<Home />` page has an `<input>` field, when the user leaves the page the `<input>` 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 `<Activity>` and toggling the `mode`:
+
+```js {6-8}
+function App() {
+  const { url } = useRouter();
+  
+  return (
+    <>
+      <Activity mode={url === '/' ? 'visible' : 'hidden'}>
+        <Home />
+      </Activity>
+      {url !== '/' && <Details />}
+    </>
+  );
+}
+```
+
+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.
+
+Try searching for a video, selecting it, and clicking "back":
+
+<Sandpack>
+
+```js src/App.js
+import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router";
+
+export default function App() {
+  const { url } = useRouter();
+  
+  return (
+    // View Transitions know about Activity
+    <ViewTransition>
+      {/* Render Home in Activity so we don't lose state */}
+      <Activity mode={url === '/' ? 'visible' : 'hidden'}>
+        <Home />
+      </Activity>
+      {url !== '/' && <Details />}
+    </ViewTransition>
+  );
+}
+```
+
+```js src/Details.js hidden
+import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react";
+import { fetchVideo, fetchVideoDetails } from "./data";
+import { Thumbnail, VideoControls } from "./Videos";
+import { useRouter } from "./router";
+import Layout from "./Layout";
+import { ChevronLeft } from "./Icons";
+
+function VideoDetails({id}) {
+  // Animate from Suspense fallback to content
+  return (
+    <Suspense
+      fallback={
+        // Animate the fallback down.
+        <ViewTransition exit="slide-down">
+          <VideoInfoFallback />
+        </ViewTransition>
+      }
+    >
+      {/* Animate the content up */}
+      <ViewTransition enter="slide-up">
+        <VideoInfo id={id} />
+      </ViewTransition>
+    </Suspense>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details() {
+  const { url, navigateBack } = useRouter();
+  const videoId = url.split("/").pop();
+  const video = use(fetchVideo(videoId));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <VideoDetails id={video.id} />
+      </div>
+    </Layout>
+  );
+}
+
+function VideoInfo({ id }) {
+  const details = use(fetchVideoDetails(id));
+  return (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+```
+
+```js src/Home.js hidden
+import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons";
+
+function SearchList({searchText, videos}) {
+  // Activate with useDeferredValue ("when") 
+  const deferredSearchText = useDeferredValue(searchText);
+  const filteredVideos = filterVideos(videos, deferredSearchText);
+  return (
+    <div className="video-list">
+      {filteredVideos.length === 0 && (
+        <div className="no-results">No results</div>
+      )}
+      <div className="videos">
+        {filteredVideos.map((video) => (
+          // Animate each item in list ("what") 
+          <ViewTransition key={video.id}>
+            <Video video={video} />
+          </ViewTransition>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+export default function Home() {
+  const videos = use(fetchVideos());
+  const count = videos.length;
+  const [searchText, setSearchText] = useState('');
+  
+  return (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <SearchList videos={videos} searchText={searchText} />
+    </Layout>
+  );
+}
+
+function SearchInput({ value, onChange }) {
+  const id = useId();
+  return (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+function filterVideos(videos, query) {
+  const keywords = query
+    .toLowerCase()
+    .split(" ")
+    .filter((s) => s !== "");
+  if (keywords.length === 0) {
+    return videos;
+  }
+  return videos.filter((video) => {
+    const words = (video.title + " " + video.description)
+      .toLowerCase()
+      .split(" ");
+    return keywords.every((kw) => words.some((w) => w.includes(kw)));
+  });
+}
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js hidden
+import { useState, unstable_ViewTransition as ViewTransition } from "react";
+import LikeButton from "./LikeButton";
+import { useRouter } from "./router";
+import { PauseIcon, PlayIcon } from "./Icons";
+import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js hidden
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* No additional animations needed */
+
+
+
+
+
+
+
+
+
+/* Previously defined animations below */
+
+
+
+
+
+
+/* Slide animations for Suspense the fallback down */
+::view-transition-old(.slide-down) {
+    animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down;
+}
+
+::view-transition-new(.slide-up) {
+    animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up;
+}
+
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* Keyframes to support our animations above. */
+@keyframes slide-up {
+    from {
+        transform: translateY(10px);
+    }
+    to {
+        transform: translateY(0);
+    }
+}
+
+@keyframes slide-down {
+    from {
+        transform: translateY(0);
+    }
+    to {
+        transform: translateY(10px);
+    }
+}
+
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+/* Default .slow-fade. */
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+### Pre-rendering with Activity {/*prerender-with-activity*/}
+
+Sometimes, you may want to prepare the next part of the UI a user is likely to use ahead of time, so it's ready by the time they are ready to use it. This is especially useful if the next route needs to suspend on data it needs to render, because you can help ensure the data is already fetched before the user navigates.
+
+For example, our app currently needs to suspend to load the data for each video when you select one. We can improve this by rendering all of the pages in a hidden `<Activity>` until the user navigates:
+
+```js {2,5,8}
+<ViewTransition>
+  <Activity mode={url === '/' ? 'visible' : 'hidden'}>
+    <Home />
+  </Activity>
+  <Activity mode={url === '/details/1' ? 'visible' : 'hidden'}>
+    <Details id={id} />
+  </Activity>
+  <Activity mode={url === '/details/1' ? 'visible' : 'hidden'}>
+    <Details id={id} />
+  </Activity>
+<ViewTransition>
+```
+
+With this update, if the content on the next page has time to pre-render, it will animate in without the Suspense fallback. Click a video, and notice that the video title and description on the Details page render immediately, without a fallback:
+
+<Sandpack>
+
+```js src/App.js
+import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, use } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; import {fetchVideos} from './data'
+
+export default function App() {
+  const { url } = useRouter();
+  const videoId = url.split("/").pop();
+  const videos = use(fetchVideos());
+  
+  return (
+    <ViewTransition>
+      {/* Render videos in Activity to pre-render them */}
+      {videos.map(({id}) => (
+        <Activity key={id} mode={videoId === id ? 'visible' : 'hidden'}>
+          <Details id={id}/>
+        </Activity>
+      ))}
+      <Activity mode={url === '/' ? 'visible' : 'hidden'}>
+        <Home />
+      </Activity>
+    </ViewTransition>
+  );
+}
+```
+
+```js src/Details.js
+import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons";
+
+function VideoDetails({id}) {
+  // Animate from Suspense fallback to content.
+  // If this is pre-rendered then the fallback
+  // won't need to show.
+  return (
+    <Suspense
+      fallback={
+        // Animate the fallback down.
+        <ViewTransition exit="slide-down">
+          <VideoInfoFallback />
+        </ViewTransition>
+      }
+    >
+      {/* Animate the content up */}
+      <ViewTransition enter="slide-up">
+        <VideoInfo id={id} />
+      </ViewTransition>
+    </Suspense>
+  );
+}
+
+function VideoInfoFallback() {
+  return (
+    <>
+      <div className="fallback title"></div>
+      <div className="fallback description"></div>
+    </>
+  );
+}
+
+export default function Details({id}) {
+  const { url, navigateBack } = useRouter();
+  const video = use(fetchVideo(id));
+
+  return (
+    <Layout
+      heading={
+        <div
+          className="fit back"
+          onClick={() => {
+            navigateBack("/");
+          }}
+        >
+          <ChevronLeft /> Back
+        </div>
+      }
+    >
+      <div className="details">
+        <Thumbnail video={video} large>
+          <VideoControls />
+        </Thumbnail>
+        <VideoDetails id={video.id} />
+      </div>
+    </Layout>
+  );
+}
+
+function VideoInfo({ id }) {
+  const details = use(fetchVideoDetails(id));
+  return (
+    <>
+      <p className="info-title">{details.title}</p>
+      <p className="info-description">{details.description}</p>
+    </>
+  );
+}
+```
+
+```js src/Home.js hidden
+import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons";
+
+function SearchList({searchText, videos}) {
+  // Activate with useDeferredValue ("when") 
+  const deferredSearchText = useDeferredValue(searchText);
+  const filteredVideos = filterVideos(videos, deferredSearchText);
+  return (
+    <div className="video-list">
+      {filteredVideos.length === 0 && (
+        <div className="no-results">No results</div>
+      )}
+      <div className="videos">
+        {filteredVideos.map((video) => (
+          // Animate each item in list ("what") 
+          <ViewTransition key={video.id}>
+            <Video video={video} />
+          </ViewTransition>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+export default function Home() {
+  const videos = use(fetchVideos());
+  const count = videos.length;
+  const [searchText, setSearchText] = useState('');
+  
+  return (
+    <Layout heading={<div className="fit">{count} Videos</div>}>
+      <SearchInput value={searchText} onChange={setSearchText} />
+      <SearchList videos={videos} searchText={searchText} />
+    </Layout>
+  );
+}
+
+function SearchInput({ value, onChange }) {
+  const id = useId();
+  return (
+    <form className="search" onSubmit={(e) => e.preventDefault()}>
+      <label htmlFor={id} className="sr-only">
+        Search
+      </label>
+      <div className="search-input">
+        <div className="search-icon">
+          <IconSearch />
+        </div>
+        <input
+          type="text"
+          id={id}
+          placeholder="Search"
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+        />
+      </div>
+    </form>
+  );
+}
+
+function filterVideos(videos, query) {
+  const keywords = query
+    .toLowerCase()
+    .split(" ")
+    .filter((s) => s !== "");
+  if (keywords.length === 0) {
+    return videos;
+  }
+  return videos.filter((video) => {
+    const words = (video.title + " " + video.description)
+      .toLowerCase()
+      .split(" ");
+    return keywords.every((kw) => words.some((w) => w.includes(kw)));
+  });
+}
+```
+
+```js src/Icons.js hidden
+export function ChevronLeft() {
+  return (
+    <svg
+      className="chevron-left"
+      xmlns="http://www.w3.org/2000/svg"
+      width="20"
+      height="20"
+      viewBox="0 0 20 20">
+      <g fill="none" fillRule="evenodd" transform="translate(-446 -398)">
+        <path
+          fill="currentColor"
+          fillRule="nonzero"
+          d="M95.8838835,240.366117 C95.3957281,239.877961 94.6042719,239.877961 94.1161165,240.366117 C93.6279612,240.854272 93.6279612,241.645728 94.1161165,242.133883 L98.6161165,246.633883 C99.1042719,247.122039 99.8957281,247.122039 100.383883,246.633883 L104.883883,242.133883 C105.372039,241.645728 105.372039,240.854272 104.883883,240.366117 C104.395728,239.877961 103.604272,239.877961 103.116117,240.366117 L99.5,243.982233 L95.8838835,240.366117 Z"
+          transform="translate(356.5 164.5)"
+        />
+        <polygon points="446 418 466 418 466 398 446 398" />
+      </g>
+    </svg>
+  );
+}
+
+export function PauseIcon() {
+  return (
+    <svg
+      className="control-icon"
+      style={{padding: '4px'}}
+      width="100"
+      height="100"
+      viewBox="0 0 512 512"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M256 0C114.617 0 0 114.615 0 256s114.617 256 256 256 256-114.615 256-256S397.383 0 256 0zm-32 320c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128zm128 0c0 8.836-7.164 16-16 16h-32c-8.836 0-16-7.164-16-16V192c0-8.836 7.164-16 16-16h32c8.836 0 16 7.164 16 16v128z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+
+export function PlayIcon() {
+  return (
+    <svg
+      className="control-icon"
+      width="100"
+      height="100"
+      viewBox="0 0 72 72"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg">
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M36 69C54.2254 69 69 54.2254 69 36C69 17.7746 54.2254 3 36 3C17.7746 3 3 17.7746 3 36C3 54.2254 17.7746 69 36 69ZM52.1716 38.6337L28.4366 51.5801C26.4374 52.6705 24 51.2235 24 48.9464V23.0536C24 20.7764 26.4374 19.3295 28.4366 20.4199L52.1716 33.3663C54.2562 34.5034 54.2562 37.4966 52.1716 38.6337Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
+export function Heart({liked, animate}) {
+  return (
+    <>
+      <svg
+        className="absolute overflow-visible"
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        <circle
+          className={`circle ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+          cx="12"
+          cy="12"
+          r="11.5"
+          fill="transparent"
+          strokeWidth="0"
+          stroke="currentColor"
+        />
+      </svg>
+
+      <svg
+        className={`heart ${liked ? 'liked' : ''} ${animate ? 'animate' : ''}`}
+        viewBox="0 0 24 24"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg">
+        {liked ? (
+          <path
+            d="M12 23a.496.496 0 0 1-.26-.074C7.023 19.973 0 13.743 0 8.68c0-4.12 2.322-6.677 6.058-6.677 2.572 0 5.108 2.387 5.134 2.41l.808.771.808-.771C12.834 4.387 15.367 2 17.935 2 21.678 2 24 4.558 24 8.677c0 5.06-7.022 11.293-11.74 14.246a.496.496 0 0 1-.26.074V23z"
+            fill="currentColor"
+          />
+        ) : (
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="m12 5.184-.808-.771-.004-.004C11.065 4.299 8.522 2.003 6 2.003c-3.736 0-6 2.558-6 6.677 0 4.47 5.471 9.848 10 13.079.602.43 1.187.82 1.74 1.167A.497.497 0 0 0 12 23v-.003c.09 0 .182-.026.26-.074C16.977 19.97 24 13.737 24 8.677 24 4.557 21.743 2 18 2c-2.569 0-5.166 2.387-5.192 2.413L12 5.184zm-.002 15.525c2.071-1.388 4.477-3.342 6.427-5.47C20.72 12.733 22 10.401 22 8.677c0-1.708-.466-2.855-1.087-3.55C20.316 4.459 19.392 4 18 4c-.726 0-1.63.364-2.5.9-.67.412-1.148.82-1.266.92-.03.025-.037.031-.019.014l-.013.013L12 7.949 9.832 5.88a10.08 10.08 0 0 0-1.33-.977C7.633 4.367 6.728 4.003 6 4.003c-1.388 0-2.312.459-2.91 1.128C2.466 5.826 2 6.974 2 8.68c0 1.726 1.28 4.058 3.575 6.563 1.948 2.127 4.352 4.078 6.423 5.466z"
+            fill="currentColor"
+          />
+        )}
+      </svg>
+    </>
+  );
+}
+
+export function IconSearch(props) {
+  return (
+    <svg width="1em" height="1em" viewBox="0 0 20 20">
+      <path
+        d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
+        stroke="currentColor"
+        fill="none"
+        strokeWidth="2"
+        fillRule="evenodd"
+        strokeLinecap="round"
+        strokeLinejoin="round"></path>
+    </svg>
+  );
+}
+```
+
+```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 (
+    <div className="page">
+      <div className="top">
+        <div className="top-nav">
+          {/* Custom classes based on transition type. */}
+          <ViewTransition
+            name="nav"
+            share={{
+              'nav-forward': 'slide-forward',
+              'nav-back': 'slide-back',
+            }}>
+            {heading}
+          </ViewTransition>
+          {isPending && <span className="loader"></span>}
+        </div>
+      </div>
+      {/* Opt-out of ViewTransition for the content. */}
+      {/* Content can define it's own ViewTransition. */}
+      <ViewTransition default="none">
+        <div className="bottom">
+          <div className="content">{children}</div>
+        </div>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+```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 (
+    <button
+      className={`like-button ${isLiked && 'liked'}`}
+      aria-label={isLiked ? 'Unsave' : 'Save'}
+      onClick={() => {
+        const nextIsLiked = !isLiked;
+        if (nextIsLiked) {
+          likedVideos.add(video.id);
+        } else {
+          likedVideos.delete(video.id);
+        }
+        setAnimate(true);
+        setIsLiked(nextIsLiked);
+      }}>
+      <Heart liked={isLiked} animate={animate} />
+    </button>
+  );
+}
+```
+
+```js src/Videos.js hidden
+import { useState, unstable_ViewTransition as ViewTransition } from "react";
+import LikeButton from "./LikeButton";
+import { useRouter } from "./router";
+import { PauseIcon, PlayIcon } from "./Icons";
+import { startTransition } from "react";
+
+export function Thumbnail({ video, children }) {
+  // Add a name to animate with a shared element transition.
+  // This uses the default animation, no additional css needed.
+  return (
+    <ViewTransition name={`video-${video.id}`}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      >
+        {children}
+      </div>
+    </ViewTransition>
+  );
+}
+
+export function VideoControls() {
+  const [isPlaying, setIsPlaying] = useState(false);
+
+  return (
+    <span
+      className="controls"
+      onClick={() =>
+        startTransition(() => {
+          setIsPlaying((p) => !p);
+        })
+      }
+    >
+      {isPlaying ? <PauseIcon /> : <PlayIcon />}
+    </span>
+  );
+}
+
+export function Video({ video }) {
+  const { navigate } = useRouter();
+
+  return (
+    <div className="video">
+      <div
+        className="link"
+        onClick={(e) => {
+          e.preventDefault();
+          navigate(`/video/${video.id}`);
+        }}
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+      <LikeButton video={video} />
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+const videos = [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  },
+  {
+    id: '2',
+    title: 'Second video',
+    description: 'Video description',
+    image: 'red',
+  },
+  {
+    id: '3',
+    title: 'Third video',
+    description: 'Video description',
+    image: 'green',
+  },
+  {
+    id: '4',
+    title: 'Fourth video',
+    description: 'Video description',
+    image: 'purple',
+  },
+  {
+    id: '5',
+    title: 'Fifth video',
+    description: 'Video description',
+    image: 'yellow',
+  },
+  {
+    id: '6',
+    title: 'Sixth video',
+    description: 'Video description',
+    image: 'gray',
+  },
+];
+
+let videosCache = new Map();
+let videoCache = new Map();
+let videoDetailsCache = new Map();
+const VIDEO_DELAY = 1;
+const VIDEO_DETAILS_DELAY = 1000;
+export function fetchVideos() {
+  if (videosCache.has(0)) {
+    return videosCache.get(0);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos);
+    }, VIDEO_DELAY);
+  });
+  videosCache.set(0, promise);
+  return promise;
+}
+
+export function fetchVideo(id) {
+  if (videoCache.has(id)) {
+    return videoCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DELAY);
+  });
+  videoCache.set(id, promise);
+  return promise;
+}
+
+export function fetchVideoDetails(id) {
+  if (videoDetailsCache.has(id)) {
+    return videoDetailsCache.get(id);
+  }
+  const promise = new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(videos.find((video) => video.id === id));
+    }, VIDEO_DETAILS_DELAY);
+  });
+  videoDetailsCache.set(id, promise);
+  return promise;
+}
+```
+
+```js src/router.js hidden
+import {useState, createContext, use, useTransition, useLayoutEffect, useEffect, unstable_addTransitionType as addTransitionType} from "react";
+
+export function Router({ children }) {
+  const [isPending, startTransition] = useTransition();
+  const [routerState, setRouterState] = useState({pendingNav: () => {}, url: document.location.pathname});
+  function navigate(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav forward"
+      addTransitionType('nav-forward');
+      go(url);
+    });
+  }
+  function navigateBack(url) {
+    startTransition(() => {
+      // Transition type for the cause "nav backward"
+      addTransitionType('nav-back');
+      go(url);
+    });
+  }
+
+  function go(url) {
+    setRouterState({
+      url,
+      pendingNav() {
+        window.history.pushState({}, "", url);
+      },
+    });
+  }
+  
+  useEffect(() => {
+    function handlePopState() {
+      // This should not animate because restoration has to be synchronous.
+      // Even though it's a transition.
+      startTransition(() => {
+        setRouterState({
+          url: document.location.pathname + document.location.search,
+          pendingNav() {
+            // Noop. URL has already updated.
+          },
+        });
+      });
+    }
+    window.addEventListener("popstate", handlePopState);
+    return () => {
+      window.removeEventListener("popstate", handlePopState);
+    };
+  }, []);
+  const pendingNav = routerState.pendingNav;
+  useLayoutEffect(() => {
+    pendingNav();
+  }, [pendingNav]);
+
+  return (
+    <RouterContext
+      value={{
+        url: routerState.url,
+        navigate,
+        navigateBack,
+        isPending,
+        params: {},
+      }}
+    >
+      {children}
+    </RouterContext>
+  );
+}
+
+const RouterContext = createContext({ url: "/", params: {} });
+
+export function useRouter() {
+  return use(RouterContext);
+}
+
+export function useIsNavPending() {
+  return use(RouterContext).isPending;
+}
+
+```
+
+```css src/styles.css hidden
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Rg.woff2) format("woff2");
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Md.woff2) format("woff2");
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 600;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: Optimistic Text;
+  src: url(https://react.dev/fonts/Optimistic_Text_W_Bd.woff2) format("woff2");
+  font-weight: 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  background-image: url(https://react.dev/images/meta-gradient-dark.png);
+  background-size: 100%;
+  background-position: -100%;
+  background-color: rgb(64 71 86);
+  background-repeat: no-repeat;
+  height: 100%;
+  width: 100%;
+}
+
+body {
+  font-family: Optimistic Text, -apple-system, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+  padding: 10px 0 10px 0;
+  margin: 0;
+  display: flex;
+  justify-content: center;
+}
+
+#root {
+  flex: 1 1;
+  height: auto;
+  background-color: #fff;
+  border-radius: 10px;
+  max-width: 450px;
+  min-height: 600px;
+  padding-bottom: 10px;
+}
+
+h1 {
+  margin-top: 0;
+  font-size: 22px;
+}
+
+h2 {
+  margin-top: 0;
+  font-size: 20px;
+}
+
+h3 {
+  margin-top: 0;
+  font-size: 18px;
+}
+
+h4 {
+  margin-top: 0;
+  font-size: 16px;
+}
+
+h5 {
+  margin-top: 0;
+  font-size: 14px;
+}
+
+h6 {
+  margin-top: 0;
+  font-size: 12px;
+}
+
+code {
+  font-size: 1.2em;
+}
+
+ul {
+  padding-inline-start: 20px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border-width: 0;
+}
+
+.absolute {
+  position: absolute;
+}
+
+.overflow-visible {
+  overflow: visible;
+}
+
+.visible {
+  overflow: visible;
+}
+
+.fit {
+  width: fit-content;
+}
+
+
+/* Layout */
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.top-hero {
+  height: 200px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-image: conic-gradient(
+      from 90deg at -10% 100%,
+      #2b303b 0deg,
+      #2b303b 90deg,
+      #16181d 1turn
+  );
+}
+
+.bottom {
+  flex: 1;
+  overflow: auto;
+}
+
+.top-nav {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 0;
+  padding: 0 12px;
+  top: 0;
+  width: 100%;
+  height: 44px;
+  color: #23272f;
+  font-weight: 700;
+  font-size: 20px;
+  z-index: 100;
+  cursor: default;
+}
+
+.content {
+  padding: 0 12px;
+  margin-top: 4px;
+}
+
+
+.loader {
+  color: #23272f;
+  font-size: 3px;
+  width: 1em;
+  margin-right: 18px;
+  height: 1em;
+  border-radius: 50%;
+  position: relative;
+  text-indent: -9999em;
+  animation: loading-spinner 1.3s infinite linear;
+  animation-delay: 200ms;
+  transform: translateZ(0);
+}
+
+@keyframes loading-spinner {
+  0%,
+  100% {
+    box-shadow: 0 -3em 0 0.2em,
+    2em -2em 0 0em, 3em 0 0 -1em,
+    2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 0;
+  }
+  12.5% {
+    box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em,
+    3em 0 0 0, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  25% {
+    box-shadow: 0 -3em 0 -0.5em,
+    2em -2em 0 0, 3em 0 0 0.2em,
+    2em 2em 0 0, 0 3em 0 -1em,
+    -2em 2em 0 -1em, -3em 0 0 -1em,
+    -2em -2em 0 -1em;
+  }
+  37.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 0, 2em 2em 0 0.2em, 0 3em 0 0em,
+    -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  50% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 0em, 0 3em 0 0.2em,
+    -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
+  }
+  62.5% {
+    box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 0,
+    -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
+  }
+  75% {
+    box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em,
+    3em 0em 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0.2em, -2em -2em 0 0;
+  }
+  87.5% {
+    box-shadow: 0em -3em 0 0, 2em -2em 0 -1em,
+    3em 0 0 -1em, 2em 2em 0 -1em, 0 3em 0 -1em,
+    -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
+  }
+}
+
+/* LikeButton */
+.like-button {
+  outline-offset: 2px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2.5rem;
+  height: 2.5rem;
+  cursor: pointer;
+  border-radius: 9999px;
+  border: none;
+  outline: none 2px;
+  color: #5e687e;
+  background: none;
+}
+
+.like-button:focus {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+}
+
+.like-button:active {
+  color: #a6423a;
+  background-color: rgba(166, 66, 58, .05);
+  transform: scaleX(0.95) scaleY(0.95);
+}
+
+.like-button:hover {
+  background-color: #f6f7f9;
+}
+
+.like-button.liked {
+  color: #a6423a;
+}
+
+/* Icons */
+@keyframes circle {
+  0% {
+    transform: scale(0);
+    stroke-width: 16px;
+  }
+
+  50% {
+    transform: scale(.5);
+    stroke-width: 16px;
+  }
+
+  to {
+    transform: scale(1);
+    stroke-width: 0;
+  }
+}
+
+.circle {
+  color: rgba(166, 66, 58, .5);
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4,0,.2,1);
+}
+
+.circle.liked.animate {
+  animation: circle .3s forwards;
+}
+
+.heart {
+  width: 1.5rem;
+  height: 1.5rem;
+}
+
+.heart.liked {
+  transform-origin: center;
+  transition-property: all;
+  transition-duration: .15s;
+  transition-timing-function: cubic-bezier(.4, 0, .2, 1);
+}
+
+.heart.liked.animate {
+  animation: scale .35s ease-in-out forwards;
+}
+
+.control-icon {
+  color: hsla(0, 0%, 100%, .5);
+  filter:  drop-shadow(0 20px 13px rgba(0, 0, 0, .03)) drop-shadow(0 8px 5px rgba(0, 0, 0, .08));
+}
+
+.chevron-left {
+  margin-top: 2px;
+  rotate: 90deg;
+}
+
+
+/* Video */
+.thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 8rem;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.thumbnail.blue {
+  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
+}
+
+.thumbnail.red {
+  background-image: conic-gradient(at top right, #c76a15, #a6423a, #2b3491);
+}
+
+.thumbnail.green {
+  background-image: conic-gradient(at top right, #c76a15, #388f7f, #2b3491);
+}
+
+.thumbnail.purple {
+  background-image: conic-gradient(at top right, #c76a15, #575fb7, #2b3491);
+}
+
+.thumbnail.yellow {
+  background-image: conic-gradient(at top right, #c76a15, #FABD62, #2b3491);
+}
+
+.thumbnail.gray {
+  background-image: conic-gradient(at top right, #c76a15, #4E5769, #2b3491);
+}
+
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+}
+
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+  cursor: pointer;
+}
+
+.video .info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-left: 8px;
+  gap: 0.125rem;
+}
+
+.video .info:hover {
+  text-decoration: underline;
+}
+
+.video-title {
+  font-size: 15px;
+  line-height: 1.25;
+  font-weight: 700;
+  color: #23272f;
+}
+
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+}
+
+/* Details */
+.details .thumbnail {
+  position: relative;
+  aspect-ratio: 16 / 9;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border-radius: 0.5rem;
+  outline-offset: 2px;
+  width: 100%;
+  vertical-align: middle;
+  background-color: #ffffff;
+  background-size: cover;
+  user-select: none;
+}
+
+.video-details-title {
+  margin-top: 8px;
+}
+
+.video-details-speaker {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  margin-left: -5px;
+  cursor: pointer;
+}
+
+.back:hover {
+  text-decoration: underline;
+}
+
+.info-title {
+  font-size: 1.5rem;
+  font-weight: 700;
+  line-height: 1.25;
+  margin: 8px 0 0 0 ;
+}
+
+.info-description {
+  margin: 8px 0 0 0;
+}
+
+.controls {
+  cursor: pointer;
+}
+
+.fallback {
+  background: #f6f7f8 linear-gradient(to right, #e6e6e6 5%, #cccccc 25%, #e6e6e6 35%) no-repeat;
+  background-size: 800px 104px;
+  display: block;
+  line-height: 1.25;
+  margin: 8px 0 0 0;
+  border-radius: 5px;
+  overflow: hidden;
+
+  animation: 1s linear 1s infinite shimmer;
+  animation-delay: 300ms;
+  animation-duration: 1s;
+  animation-fill-mode: forwards;
+  animation-iteration-count: infinite;
+  animation-name: shimmer;
+  animation-timing-function: linear;
+}
+
+
+.fallback.title {
+  width: 130px;
+  height: 30px;
+
+}
+
+.fallback.description {
+  width: 150px;
+  height: 21px;
+}
+
+@keyframes shimmer {
+  0% {
+    background-position: -468px 0;
+  }
+
+  100% {
+    background-position: 468px 0;
+  }
+}
+
+.search {
+  margin-bottom: 10px;
+}
+.search-input {
+  width: 100%;
+  position: relative;
+}
+
+.search-icon {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  inset-inline-start: 0;
+  display: flex;
+  align-items: center;
+  padding-inline-start: 1rem;
+  pointer-events: none;
+  color: #99a1b3;
+}
+
+.search-input input {
+  display: flex;
+  padding-inline-start: 2.75rem;
+  padding-top: 10px;
+  padding-bottom: 10px;
+  width: 100%;
+  text-align: start;
+  background-color: rgb(235 236 240);
+  outline: 2px solid transparent;
+  cursor: pointer;
+  border: none;
+  align-items: center;
+  color: rgb(35 39 47);
+  border-radius: 9999px;
+  vertical-align: middle;
+  font-size: 15px;
+}
+
+.search-input input:hover, .search-input input:active {
+  background-color: rgb(235 236 240/ 0.8);
+  color: rgb(35 39 47/ 0.8);
+}
+
+/* Home */
+.video-list {
+  position: relative;
+}
+
+.video-list .videos {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  overflow-y: auto;
+  height: 100%;
+}
+```
+
+
+```css src/animations.css
+/* No additional animations needed */
+
+
+
+
+
+
+
+
+
+/* Previously defined animations below */
+
+
+
+
+
+
+/* Slide animations for Suspense the fallback down */
+::view-transition-old(.slide-down) {
+    animation: 150ms ease-out both fade-out, 150ms ease-out both slide-down;
+}
+
+::view-transition-new(.slide-up) {
+    animation: 210ms ease-in 150ms both fade-in, 400ms ease-in both slide-up;
+}
+
+/* Animations for view transition classed added by transition type */
+::view-transition-old(.slide-forward) {
+    /* when sliding forward, the "old" page should slide out to left. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+}
+
+::view-transition-new(.slide-forward) {
+    /* when sliding forward, the "new" page should slide in from right. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+}
+
+::view-transition-old(.slide-back) {
+    /* when sliding back, the "old" page should slide out to right. */
+    animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+}
+
+::view-transition-new(.slide-back) {
+    /* when sliding back, the "new" page should slide in from left. */
+    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
+    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+}
+
+/* Keyframes to support our animations above. */
+@keyframes slide-up {
+    from {
+        transform: translateY(10px);
+    }
+    to {
+        transform: translateY(0);
+    }
+}
+
+@keyframes slide-down {
+    from {
+        transform: translateY(0);
+    }
+    to {
+        transform: translateY(10px);
+    }
+}
+
+@keyframes fade-in {
+    from {
+        opacity: 0;
+    }
+}
+
+@keyframes fade-out {
+    to {
+        opacity: 0;
+    }
+}
+
+@keyframes slide-to-right {
+    to {
+        transform: translateX(50px);
+    }
+}
+
+@keyframes slide-from-right {
+    from {
+        transform: translateX(50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+@keyframes slide-to-left {
+    to {
+        transform: translateX(-50px);
+    }
+}
+
+@keyframes slide-from-left {
+    from {
+        transform: translateX(-50px);
+    }
+    to {
+        transform: translateX(0);
+    }
+}
+
+/* Default .slow-fade. */
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+```js src/index.js hidden
+import React, {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import './styles.css';
+import './animations.css';
+
+import App from './App';
+import {Router} from './router';
+
+const root = createRoot(document.getElementById('root'));
+root.render(
+  <StrictMode>
+    <Router>
+      <App />
+    </Router>
+  </StrictMode>
+);
+```
+
+```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"
+  }
+}
+```
+
+</Sandpack>
+
+### Server-Side Rendering with Activity {/*server-side-rendering-with-activity*/}
+
+When using Activity on a page that uses server-side rendering (SSR), there are additional optimizations.
+
+If part of the page is rendered with `mode="hidden"`, then it will not be included in the SSR response. Instead, React will schedule a client render for the content inside Activity while the rest of the page hydrates, prioritizing the visible content on screen.
+
+For parts of the UI rendered with `mode="visible"`, React will de-prioritize hydration of content within Activity, similar to how Suspense content is hydrated at a lower priority. If the user interacts with the page, we'll prioritize hydration within the boundary if needed.
+
+These are advanced use cases, but they show the additional benefits considered with Activity.
+
+### Future modes for Activity {/*future-modes-for-activity*/}
+
+In the future, we may add more modes to Activity.
+
+For example, a common use case is rendering a modal, where the previous "inactive" page is visible behind the "active" modal view. The "hidden" mode does not work for this use case because it's not visible and not included in SSR.
+
+Instead, we're considering a new mode that would keep the content visible&mdash;and included in SSR&mdash;but keep it unmounted and de-prioritize updates. This mode may also need to "pause" DOM updates, since it can be distracting to see backgrounded content updating while a modal is open.
+
+Another mode we're considering for Activity is the ability to automatically destroy state for hidden Activities if there is too much memory being used. Since the component is already unmounted, it may be preferable to destroy state for the least recently used hidden parts of the app rather than consume too many resources.
+
+These are areas we're still exploring, and we'll share more as we make progress. For more information on what Activity includes today, [check out the docs](/reference/react/Activity).
+
+---
+
+# Features in development {/*features-in-development*/}
+
+We're also developing features to help solve the common problems below. 
+
+As we iterate on possible solutions, you may see some potential APIs we're testing being shared based on the PRs we are landing. Please keep in mind that as we try different ideas, we often change or remove different solutions after trying them out. 
+
+When the solutions we're working on are shared too early, it can create churn and confusion in the community. To balance being transparent and limiting confusion, we're sharing the problems we're currently developing solutions for, without sharing a particular solution we have in mind. 
+
+As these features progress, we'll announce them on the blog with docs included so you can try them out. 
+
+## React Performance Tracks {/*react-performance-tracks*/}
+
+We're working on a new set of custom tracks to performance profilers using browser APIs that [allow adding custom tracks](https://developer.chrome.com/docs/devtools/performance/extension) to provide more information about the performance of your React app.
+
+This feature is still in progress, so we're not ready to publish docs to fully release it as an experimental feature yet. You can get a sneak preview when using an experimental version of React, which will automatically add the performance tracks to profiles:
+
+<div style={{display: 'flex', justifyContent: 'center', marginBottom: '1rem'}}>
+  <picture >
+      <source srcset="/images/blog/react-labs-april-2025/perf_tracks.png" />
+      <img className="w-full light-image" src="/images/blog/react-labs-april-2025/perf_tracks.webp" />
+  </picture>
+  <picture >
+      <source srcset="/images/blog/react-labs-april-2025/perf_tracks_dark.png" />
+      <img className="w-full dark-image" src="/images/blog/react-labs-april-2025/perf_tracks_dark.webp" />
+  </picture>
+</div>
+
+There are a few known issues we plan to address such as performance, and the scheduler track not always "connecting" work across Suspended trees, so it's not quite ready to try. We're also still collecting feedback from early adopters to improve the design and usability of the tracks.
+
+Once we solve those issues, we'll publish experimental docs and share that it's ready to try.
+
+---
+
+## Automatic Effect Dependencies {/*automatic-effect-dependencies*/}
+
+When we released hooks, we had three motivations:
+
+- **Sharing code between components**: hooks replaced patterns like render props and higher-order components to allow you to reuse stateful logic without changing your component hierarchy.
+- **Think in terms of function, not lifecycles**: hooks let you split one component into smaller functions based on what pieces are related (such as setting up a subscription or fetching data), rather than forcing a split based on lifecycle methods.
+- **Support ahead-of-time compilation**: hooks were designed to support ahead-of-time compilation with less pitfalls causing unintentional de-optimizations caused by lifecycle methods, and limitations of classes.
+
+Since their release, hooks have been successful at *sharing code between components*. Hooks are now the favored way to share logic between components, and there are less use cases for render props and higher order components. Hooks have also been successful at supporting features like Fast Refresh that were not possible with class components. 
+
+### Effects can be hard {/*effects-can-be-hard*/}
+
+Unfortunately, some hooks are still hard to think in terms of function instead of lifecycles. Effects specifically are still hard to understand and are the most common pain point we hear from developers. Last year, we spent a significant amount of time researching how Effects were used, and how those use cases could be simplified and easier to understand.
+
+We found that often, the confusion is from using an Effect when you don't need to. The [You Might Not Need an Effect](/learn/you-might-not-need-an-effect) guide covers many cases for when Effects are not the right solution. However, even when an Effect is the right fit for a problem, Effects can still be harder to understand than class component lifecycles.
+
+We believe one of the reasons for confusion is that developers to think of Effects from the _component's_ perspective (like a lifecycle), instead of the _Effects_ point of view (what the Effect does).
+
+Let's look at an example [from the docs](/learn/lifecycle-of-reactive-effects#thinking-from-the-effects-perspective):
+
+```js
+useEffect(() => {
+  // Your Effect connected to the room specified with roomId...
+  const connection = createConnection(serverUrl, roomId);
+  connection.connect();
+  return () => {
+    // ...until it disconnected
+    connection.disconnect();
+  };
+}, [roomId]);
+```
+
+Many users would read this code as "on mount, connect to the roomId. whenever `roomId` changes, disconnect to the old room and re-create the connection". However, this is thinking from the component's lifecycle perspective, which means you will need to think of every component lifecycle state to write the Effect correctly. This can be difficult, so it's understandable that Effects seem harder than class lifecycles when using the component perspective.
+
+### Effects without dependencies {/*effects-without-dependencies*/}
+
+Instead, it's better to think from the Effect's perspective. The Effect doesn't know about the component lifecycles. It only describes how to start synchronization and how to stop it. When users think of Effects in this way, their Effects tend to be easier to write, and more resilient to being started and stopped as many times as is needed.
+
+We spent some time researching why Effects are thought of from the component perspective, and we think one of the reasons is the dependency array. Since you have to write it, it's right there and in your face reminding you of what you're "reacting" to and baiting you into the mental model of 'do this when these values change'.
+
+When we released hooks, we knew we could make them easier to use with ahead-of-time compilation. With the React Compiler, you're now able to avoid writing `useCallback` and `useMemo` yourself in most cases. For Effects, the compiler can insert the dependencies for you:
+
+```js
+useEffect(() => {
+  const connection = createConnection(serverUrl, roomId);
+  connection.connect();
+  return () => {
+    connection.disconnect();
+  };
+}); // compiler inserted dependencies. 
+```
+
+With this code, the React Compiler can infer the dependencies for you and insert them automatically so you don't need to see or write them. With features like [the IDE extension](#compiler-ide-extension) and [`useEffectEvent`](/reference/react/experimental_useEffectEvent), we can provide a CodeLens to show you what the Compiler inserted for times you need to debug, or to optimize by removing a dependency. This helps reinforce the correct mental model for writing Effects, which can run at any time to synchronize your component or hook's state with something else.
+
+Our hope is that automatically inserting dependencies is not only easier to write, but that it also makes them easier to understand by forcing you to think in terms of what the Effect does, and not in component lifecycles. 
+
+---
+
+## Compiler IDE Extension {/*compiler-ide-extension*/}
+
+Earlier this week [we shared](/blog/2025/04/21/react-compiler-rc) the React Compiler release candidate, and we're working towards shipping the first SemVer stable version of the compiler in the coming months.
+
+We've also begun exploring ways to use the React Compiler to provide information that can improve understanding and debugging your code. One idea we've started exploring is a new experimental LSP-based React IDE extension powered by React Compiler, similar to the extension used in [Lauren Tan's React Conf talk](https://conf2024.react.dev/talks/5).
+
+Our idea is that we can use the compiler's static analysis to provide more information, suggestions, and optimization opportunities directly in your IDE. For example, we can provide diagnostics for code breaking the Rules of React, hovers to show if components and hooks were optimized by the compiler, or a CodeLens to see [automatically inserted Effect dependencies](#automatic-effect-dependencies).
+
+The IDE extension is still an early exploration, but we'll share our progress in future updates.
+
+---
+
+## Fragment Refs {/*fragment-refs*/}
+
+Many DOM APIs like those for event management, positioning, and focus are difficult to compose when writing with React. This often leads developers to reach for Effects, managing multiple Refs, by using APIs like `findDOMNode` (removed in React 19).
+
+We are exploring adding refs to Fragments that would point to a group of DOM elements, rather than just a single element. Our hope is that this will simplify managing multiple children and make it easier to write composable React code when calling DOM APIs.
+
+Fragment refs are still being researched. We'll share more when we're closer to having the final API finished.
+
+---
+
+## Gesture Animations {/*gesture-animations*/}
+
+We're also researching ways to enhance View Transitions to support gesture animations such as swiping to open a menu, or scroll through a photo carousel. 
+
+Gestures present new challenges for a few reasons:
+
+- **Gestures are continuous**: as you swipe the animation is tied to your finger placement time, rather than triggering and running to completion.
+- **Gestures don't complete**: when you release your finger gesture animations can run to completion, or revert to their original state (like when you only partially open a menu) depending on how far you go.
+- **Gestures invert old and new**: while you're animating, you want the page you are animating from to stay "alive" and interactive. This inverts the browser View Transition model where the "old" state is a snapshot and the "new" state is the live DOM.
+
+We believe we’ve found an approach that works well and may introduce a new API for triggering gesture transitions. For now, we're focused on shipping `<ViewTransition>`, and will revisit gestures afterward.
+
+---
+
+## Concurrent Stores {/*concurrent-stores*/}
+
+When we released React 18 with concurrent rendering, we also released `useSyncExternalStore` so external store libraries that did not use React state or context could [support concurrent rendering](https://github.com/reactwg/react-18/discussions/70) by forcing a synchronous render when the store is updated.
+
+Using `useSyncExternalStore` comes at a cost though, since it forces a bail out from concurrent features like transitions, and forces existing content to show Suspense fallbacks.
+
+Now that React 19 has shipped, we're revisiting this problem space to create a primitive to fully support concurrent external stores with the `use` API:
+
+```js
+const value = use(store);
+```
+
+Our goal is to allow external state to be read during render without tearing, and to work seamlessly with all of the concurrent features React offers. 
+
+This research is still early. We'll share more, and what the new APIs will look like, when we're further along. 
+
+---
+
+_Thanks to [Aurora Scharff](https://bsky.app/profile/aurorascharff.no), [Dan Abramov](https://bsky.app/profile/danabra.mov), [Eli White](https://twitter.com/Eli_White), [Lauren Tan](https://bsky.app/profile/no.lol), [Luna Wei](https://github.com/lunaleaps), [Matt Carroll](https://twitter.com/mattcarrollcode), [Jack Pope](https://jackpope.me), [Jason Bonta](https://threads.net/someextent), [Jordan Brown](https://github.com/jbrown215), [Jordan Eldredge](https://bsky.app/profile/capt.dev), [Mofei Zhang](https://threads.net/z_mofei), [Sebastien Lorber](https://bsky.app/profile/sebastienlorber.com), [Sebastian Markbåge](https://bsky.app/profile/sebmarkbage.calyptus.eu), and [Tim Yung](https://github.com/yungsters) for reviewing this post._
diff --git a/src/content/blog/index.md b/src/content/blog/index.md
index f7bbe76f3..a7a897634 100644
--- a/src/content/blog/index.md
+++ b/src/content/blog/index.md
@@ -4,7 +4,7 @@ title: React Blog
 
 <Intro>
 
-This blog is the official source for the updates from the React team. Anything important, including release notes or deprecation notices, will be posted here first. 
+This blog is the official source for the updates from the React team. Anything important, including release notes or deprecation notices, will be posted here first.
 
 You can also follow the [@react.dev](https://bsky.app/profile/react.dev) account on Bluesky, or [@reactjs](https://twitter.com/reactjs) account on Twitter, but you won’t miss anything essential if you only read this blog.
 
@@ -12,7 +12,19 @@ You can also follow the [@react.dev](https://bsky.app/profile/react.dev) account
 
 <div className="sm:-mx-5 flex flex-col gap-5 mt-12">
 
-<BlogCard title="Sunsetting Create React App" date="February 13, 2025" url="/blog/2025/02/14/sunsetting-create-react-app">
+<BlogCard title="React Labs: View Transitions, Activity, and more" date="April 23, 2025" url="/blog/2025/04/23/react-labs-view-transitions-activity-and-more">
+
+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 sharing other areas we're working on now ...
+
+</BlogCard>
+
+<BlogCard title="React Compiler RC" date="April 21, 2025" url="/blog/2025/04/21/react-compiler-rc">
+
+We are releasing the compiler's first Release Candidate (RC) today.
+
+</BlogCard>
+
+<BlogCard title="Sunsetting Create React App" date="February 14, 2025" url="/blog/2025/02/14/sunsetting-create-react-app">
 
 Today, we’re deprecating Create React App for new apps, and encouraging existing apps to migrate to a framework, or to migrate to a build tool like Vite, Parcel, or RSBuild. We’re also providing docs for when a framework isn’t a good fit for your project, you want to build your own framework, or you just want to learn how React works by building a React app from scratch ...
 
diff --git a/src/content/community/conferences.md b/src/content/community/conferences.md
index 46636d4c9..a2bcd196e 100644
--- a/src/content/community/conferences.md
+++ b/src/content/community/conferences.md
@@ -10,16 +10,6 @@ Do you know of a local React.js conference? Add it here! (Please keep the list c
 
 ## Upcoming Conferences {/*upcoming-conferences*/}
 
-### React Paris 2025 {/*react-paris-2025*/}
-March 20 - 21, 2025. In-person in Paris, France (hybrid event)
-
-[Website](https://react.paris/) - [Twitter](https://x.com/BeJS_)
-
-### React Native Connection 2025 {/*react-native-connection-2025*/}
-April 3 (Reanimated Training) + April 4 (Conference), 2025. Paris, France.
-
-[Website](https://reactnativeconnection.io/) - [X](https://x.com/reactnativeconn) - [Bluesky](https://bsky.app/profile/reactnativeconnect.bsky.social)
-
 ### CityJS London 2025 {/*cityjs-london*/}
 April 23 - 25, 2025. In-person in London, UK 
 
@@ -35,6 +25,11 @@ May 27 - 31, 2025. In-person in Athens, Greece
 
 [Website](https://athens.cityjsconf.org/) - [Twitter](https://x.com/cityjsconf) - [Bluesky](https://bsky.app/profile/cityjsconf.bsky.social)
 
+### React Norway 2025 {/*react-norway-2025*/}
+June 13, 2025. In-person in Oslo, Norway + remote (virtual event)
+
+[Website](https://reactnorway.com/) - [Twitter](https://x.com/ReactNorway)
+
 ### React Summit 2025 {/*react-summit-2025*/}
 June 13 - 17, 2025. In-person in Amsterdam, Netherlands + remote (hybrid event)
 
@@ -50,14 +45,39 @@ September 2-4, 2025. Wrocław, Poland.
 
 [Website](https://www.reactuniverseconf.com/) - [Twitter](https://twitter.com/react_native_eu) - [LinkedIn](https://www.linkedin.com/events/reactuniverseconf7163919537074118657/)
 
+### React Conf 2025 {/*react-conf-2025*/}
+October 7-8, 2025. Henderson, Nevada, USA and free livestream
+
+[Website](https://conf.react.dev/) - [Twitter](https://x.com/reactjs) - [Bluesky](https://bsky.app/profile/react.dev)
+
 ### React India 2025 {/*react-india-2025*/}
 October 31 - November 01, 2025. In-person in Goa, India (hybrid event) + Oct 15 2025 - remote day
 
 [Website](https://www.reactindia.io) - [Twitter](https://twitter.com/react_india) - [Facebook](https://www.facebook.com/ReactJSIndia) - [Youtube](https://www.youtube.com/channel/UCaFbHCBkPvVv1bWs_jwYt3w)
 
+### React Summit US 2025 {/*react-summit-us-2025*/}
+November 18 - 21, 2025. In-person in New York, USA + remote (hybrid event)
+
+[Website](https://reactsummit.us/) - [Twitter](https://x.com/reactsummit)
+
+### React Advanced London 2025 {/*react-advanced-london-2025*/}
+November 28 & December 1, 2025. In-person in London, UK + online (hybrid event)
+
+[Website](https://reactadvanced.com/) - [Twitter](https://x.com/reactadvanced)
+
 
 ## Past Conferences {/*past-conferences*/}
 
+### React Paris 2025 {/*react-paris-2025*/}
+March 20 - 21, 2025. In-person in Paris, France (hybrid event)
+
+[Website](https://react.paris/) - [Twitter](https://x.com/BeJS_)
+
+### React Native Connection 2025 {/*react-native-connection-2025*/}
+April 3 (Reanimated Training) + April 4 (Conference), 2025. Paris, France.
+
+[Website](https://reactnativeconnection.io/) - [X](https://x.com/reactnativeconn) - [Bluesky](https://bsky.app/profile/reactnativeconnect.bsky.social)
+
 ### React Day Berlin 2024 {/*react-day-berlin-2024*/}
 December 13 & 16, 2024. In-person in Berlin, Germany + remote (hybrid event)
 
diff --git a/src/content/community/index.md b/src/content/community/index.md
index e6ad26c27..8628ed76d 100644
--- a/src/content/community/index.md
+++ b/src/content/community/index.md
@@ -29,4 +29,8 @@ Hər cəmiyyətdə minlərə React proqramçıları var.
 
 ## Xəbərlər {/*news*/}
 
+<<<<<<< HEAD
 React haqqında ən son xəbərlər üçün [**@reactjs** Twitter səhifəsini](https://twitter.com/reactjs) və [rəsmi React bloqunu](/blog/) izləyin.
+=======
+For the latest news about React, [follow **@reactjs** on Twitter](https://twitter.com/reactjs), [**@react.dev** on Bluesky](https://bsky.app/profile/react.dev) and the [official React blog](/blog/) on this website.
+>>>>>>> 2571aee6dba2e9790172a70224dac8371640b772
diff --git a/src/content/community/meetups.md b/src/content/community/meetups.md
index 6449e8418..b4d803c75 100644
--- a/src/content/community/meetups.md
+++ b/src/content/community/meetups.md
@@ -38,7 +38,7 @@ Yerli React.js yığıncağınız (meetup) varsa, buraya əlavə edin! (Xahiş e
 
 ## Canada {/*canada*/}
 * [Halifax, NS](https://www.meetup.com/Halifax-ReactJS-Meetup/)
-* [Montreal, QC - React Native](https://www.meetup.com/fr-FR/React-Native-MTL/)
+* [Montreal, QC](https://guild.host/react-montreal/)
 * [Vancouver, BC](https://www.meetup.com/ReactJS-Vancouver-Meetup/)
 * [Ottawa, ON](https://www.meetup.com/Ottawa-ReactJS-Meetup/)
 * [Saskatoon, SK](https://www.meetup.com/saskatoon-react-meetup/)
@@ -47,6 +47,9 @@ Yerli React.js yığıncağınız (meetup) varsa, buraya əlavə edin! (Xahiş e
 ## Colombia {/*colombia*/}
 * [Medellin](https://www.meetup.com/React-Medellin/)
 
+## Czechia {/*czechia*/}
+* [Prague](https://guild.host/react-prague/)
+
 ## Denmark {/*denmark*/}
 * [Aalborg](https://www.meetup.com/Aalborg-React-React-Native-Meetup/)
 * [Aarhus](https://www.meetup.com/Aarhus-ReactJS-Meetup/)
@@ -78,7 +81,7 @@ Yerli React.js yığıncağınız (meetup) varsa, buraya əlavə edin! (Xahiş e
 * [Thessaloniki](https://www.meetup.com/Thessaloniki-ReactJS-Meetup/)
 
 ## India {/*india*/}
-* [Ahmedabad](https://www.meetup.com/react-ahmedabad/)
+* [Ahmedabad](https://reactahmedabad.dev/)
 * [Bangalore (React)](https://www.meetup.com/ReactJS-Bangalore/)
 * [Bangalore (React Native)](https://www.meetup.com/React-Native-Bangalore-Meetup)
 * [Chennai](https://www.linkedin.com/company/chennaireact)
@@ -166,6 +169,7 @@ Yerli React.js yığıncağınız (meetup) varsa, buraya əlavə edin! (Xahiş e
 * [Cleveland, OH - ReactJS](https://www.meetup.com/Cleveland-React/)
 * [Columbus, OH - ReactJS](https://www.meetup.com/ReactJS-Columbus-meetup/)
 * [Dallas, TX - ReactJS](https://www.meetup.com/ReactDallas/)
+* [Denver, CO - React Denver](https://reactdenver.com/)
 * [Detroit, MI - Detroit React User Group](https://www.meetup.com/Detroit-React-User-Group/)
 * [Indianapolis, IN - React.Indy](https://www.meetup.com/React-Indy)
 * [Irvine, CA - ReactJS](https://www.meetup.com/ReactJS-OC/)
diff --git a/src/content/learn/build-a-react-app-from-scratch.md b/src/content/learn/build-a-react-app-from-scratch.md
index e5b396c7e..b5f29e9af 100644
--- a/src/content/learn/build-a-react-app-from-scratch.md
+++ b/src/content/learn/build-a-react-app-from-scratch.md
@@ -65,7 +65,7 @@ Rsbuild includes built-in support for React features like fast refresh, JSX, Typ
 
 #### Metro for React Native {/*react-native*/}
 
-If you'd you're starting from scratch with React Native you'll need to use [Metro](https://metrobundler.dev/), the JavaScript bundler for React Native. Metro supports bundling for platforms like iOS and Android, but lacks many features when compared to the tools here. We recommend starting with Vite, Parcel, or Rsbuild unless your project requires React Native support.
+If you're starting from scratch with React Native you'll need to use [Metro](https://metrobundler.dev/), the JavaScript bundler for React Native. Metro supports bundling for platforms like iOS and Android, but lacks many features when compared to the tools here. We recommend starting with Vite, Parcel, or Rsbuild unless your project requires React Native support.
 
 </Note>
 
@@ -83,7 +83,7 @@ Routers are a core part of modern applications, and are usually integrated with
 
 We suggest using:
 
-- [React Router](https://reactrouter.com/start/framework/custom)
+- [React Router](https://reactrouter.com/start/data/custom)
 - [Tanstack Router](https://tanstack.com/router/latest)
 
 
@@ -116,7 +116,7 @@ Similarly, if you rely on the apps using your framework to split the code, you m
 Splitting code by route, when integrated with bundling and data fetching, can reduce the initial load time of your app and the time it takes for the largest visible content of the app to render ([Largest Contentful Paint](https://web.dev/articles/lcp)).
 
 For code-splitting instructions, see your build tool docs:
-- [Vite build optimizations](https://v3.vitejs.dev/guide/features.html#build-optimizations)
+- [Vite build optimizations](https://vite.dev/guide/features.html#build-optimizations)
 - [Parcel code splitting](https://parceljs.org/features/code-splitting/)
 - [Rsbuild code splitting](https://rsbuild.dev/guide/optimization/code-splitting)
 
diff --git a/src/content/learn/creating-a-react-app.md b/src/content/learn/creating-a-react-app.md
index fc6c956d4..df512cca8 100644
--- a/src/content/learn/creating-a-react-app.md
+++ b/src/content/learn/creating-a-react-app.md
@@ -32,7 +32,7 @@ This allows you to start with a client-only app, and if your needs change later,
 npx create-next-app@latest
 </TerminalBlock>
 
-Next.js is maintained by [Vercel](https://vercel.com/). You can [deploy a Next.js app](https://nextjs.org/docs/app/building-your-application/deploying) to any Node.js or serverless hosting, or to your own server. Next.js also supports [static export](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) which doesn't require a server. Vercel additionally provides opt-in paid cloud services.
+Next.js is maintained by [Vercel](https://vercel.com/). You can [deploy a Next.js app](https://nextjs.org/docs/app/building-your-application/deploying) to any hosting provider that supports Node.js or Docker containers, or to your own server. Next.js also supports [static export](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) which doesn't require a server.
 
 ### React Router (v7) {/*react-router-v7*/}
 
@@ -106,7 +106,7 @@ If your app has constraints not well-served by existing frameworks, you prefer t
 
 Starting from scratch gives you more flexibility, but does require that you make choices on which tools to use for routing, data fetching, and other common usage patterns.  It's a lot like building your own framework, instead of using a framework that already exists. The [frameworks we recommend](#full-stack-frameworks) have built-in solutions for these problems.  
 
-If you want to build your own solutions, see our guide to [build a React app from Scratch](/learn/build-a-react-app-from-scratch) for instructions on how to set up a new React project starting with a built tool like [Vite](https://vite.dev/), [Parcel](https://parceljs.org/), or [RSbuild](https://rsbuild.dev/).
+If you want to build your own solutions, see our guide to [build a React app from Scratch](/learn/build-a-react-app-from-scratch) for instructions on how to set up a new React project starting with a build tool like [Vite](https://vite.dev/), [Parcel](https://parceljs.org/), or [RSbuild](https://rsbuild.dev/).
 
 -----
 
diff --git a/src/content/learn/preserving-and-resetting-state.md b/src/content/learn/preserving-and-resetting-state.md
index d35071845..bf5531f35 100644
--- a/src/content/learn/preserving-and-resetting-state.md
+++ b/src/content/learn/preserving-and-resetting-state.md
@@ -2011,7 +2011,7 @@ export default function ContactList() {
       <label>
         <input
           type="checkbox"
-          value={reverse}
+          checked={reverse}
           onChange={e => {
             setReverse(e.target.checked)
           }}
@@ -2110,7 +2110,7 @@ export default function ContactList() {
       <label>
         <input
           type="checkbox"
-          value={reverse}
+          checked={reverse}
           onChange={e => {
             setReverse(e.target.checked)
           }}
diff --git a/src/content/learn/react-compiler.md b/src/content/learn/react-compiler.md
index 7c46673e7..9d4e19d21 100644
--- a/src/content/learn/react-compiler.md
+++ b/src/content/learn/react-compiler.md
@@ -6,10 +6,6 @@ title: React Compiler
 This page will give you an introduction to React Compiler and how to try it out successfully.
 </Intro>
 
-<Wip>
-These docs are still a work in progress. More documentation is available in the [React Compiler Working Group repo](https://github.com/reactwg/react-compiler/discussions), and will be upstreamed into these docs when they are more stable.
-</Wip>
-
 <YouWillLearn>
 
 * Getting started with the compiler
@@ -19,25 +15,25 @@ These docs are still a work in progress. More documentation is available in the
 </YouWillLearn>
 
 <Note>
-React Compiler is a new compiler currently in Beta, that we've open sourced to get early feedback from the community. While it has been used in production at companies like Meta, rolling out the compiler to production for your app will depend on the health of your codebase and how well you’ve followed the [Rules of React](/reference/rules).
+React Compiler is a new compiler currently in RC, that we've open sourced to get feedback from the community. We now recommend everyone to try the compiler and provide feedback.
 
-The latest Beta release can be found with the `@beta` tag, and daily experimental releases with `@experimental`.
+The latest RC release can be found with the `@rc` tag, and daily experimental releases with `@experimental`.
 </Note>
 
-React Compiler is a new compiler that we've open sourced to get early feedback from the community. It is a build-time only tool that automatically optimizes your React app. It works with plain JavaScript, and understands the [Rules of React](/reference/rules), so you don't need to rewrite any code to use it.
+React Compiler is a new compiler that we've open sourced to get feedback from the community. It is a build-time only tool that automatically optimizes your React app. It works with plain JavaScript, and understands the [Rules of React](/reference/rules), so you don't need to rewrite any code to use it.
 
-The compiler also includes an [ESLint plugin](#installing-eslint-plugin-react-compiler) that surfaces the analysis from the compiler right in your editor. **We strongly recommend everyone use the linter today.** The linter does not require that you have the compiler installed, so you can use it even if you are not ready to try out the compiler.
+eslint-plugin-react-hooks also includes an [ESLint rule](#installing-eslint-plugin-react-compiler) that surfaces the analysis from the compiler right in your editor. **We strongly recommend everyone use the linter today.** The linter does not require that you have the compiler installed, so you can use it even if you are not ready to try out the compiler.
 
-The compiler is currently released as `beta`, and is available to try out on React 17+ apps and libraries. To install the Beta:
+The compiler is currently released as `rc`, and is available to try out on React 17+ apps and libraries. To install the RC:
 
 <TerminalBlock>
-npm install -D babel-plugin-react-compiler@beta eslint-plugin-react-compiler@beta
+{`npm install -D babel-plugin-react-compiler@rc eslint-plugin-react-hooks@^6.0.0-rc.1`}
 </TerminalBlock>
 
 Or, if you're using Yarn:
 
 <TerminalBlock>
-yarn add -D babel-plugin-react-compiler@beta eslint-plugin-react-compiler@beta
+{`yarn add -D babel-plugin-react-compiler@rc eslint-plugin-react-hooks@^6.0.0-rc.1`}
 </TerminalBlock>
 
 If you are not using React 19 yet, please see [the section below](#using-react-compiler-with-react-17-or-18) for further instructions.
@@ -116,7 +112,7 @@ So if `expensivelyProcessAReallyLargeArrayOfObjects` was used in many different
 
 ### Should I try out the compiler? {/*should-i-try-out-the-compiler*/}
 
-Please note that the compiler is still in Beta and has many rough edges. While it has been used in production at companies like Meta, rolling out the compiler to production for your app will depend on the health of your codebase and how well you've followed the [Rules of React](/reference/rules).
+The compiler is now in RC and has been tested extensively in production. While it has been used in production at companies like Meta, rolling out the compiler to production for your app will depend on the health of your codebase and how well you've followed the [Rules of React](/reference/rules).
 
 **You don't have to rush into using the compiler now. It's okay to wait until it reaches a stable release before adopting it.** However, we do appreciate trying it out in small experiments in your apps so that you can [provide feedback](#reporting-issues) to us to help make the compiler better.
 
@@ -124,43 +120,15 @@ Please note that the compiler is still in Beta and has many rough edges. While i
 
 In addition to these docs, we recommend checking the [React Compiler Working Group](https://github.com/reactwg/react-compiler) for additional information and discussion about the compiler.
 
-### Installing eslint-plugin-react-compiler {/*installing-eslint-plugin-react-compiler*/}
+### Installing eslint-plugin-react-hooks {/*installing-eslint-plugin-react-compiler*/}
 
-React Compiler also powers an ESLint plugin. The ESLint plugin can be used **independently** of the compiler, meaning you can use the ESLint plugin even if you don't use the compiler.
+React Compiler also powers an ESLint plugin. You can try it out by installing eslint-plugin-react-hooks@^6.0.0-rc.1.
 
 <TerminalBlock>
-npm install -D eslint-plugin-react-compiler@beta
+{`npm install -D eslint-plugin-react-hooks@^6.0.0-rc.1`}
 </TerminalBlock>
 
-Then, add it to your ESLint config:
-
-```js
-import reactCompiler from 'eslint-plugin-react-compiler'
-
-export default [
-  {
-    plugins: {
-      'react-compiler': reactCompiler,
-    },
-    rules: {
-      'react-compiler/react-compiler': 'error',
-    },
-  },
-]
-```
-
-Or, in the deprecated eslintrc config format:
-
-```js
-module.exports = {
-  plugins: [
-    'eslint-plugin-react-compiler',
-  ],
-  rules: {
-    'react-compiler/react-compiler': 'error',
-  },
-}
-```
+See our [editor setup](/learn/editor-setup#linting) guide for more details.
 
 The ESLint plugin will display any violations of the rules of React in your editor. When it does this, it means that the compiler has skipped over optimizing that component or hook. This is perfectly okay, and the compiler can recover and continue optimizing other components in your codebase.
 
@@ -194,7 +162,7 @@ If you're starting a new project, you can enable the compiler on your entire cod
 React Compiler works best with React 19 RC. If you are unable to upgrade, you can install the extra `react-compiler-runtime` package which will allow the compiled code to run on versions prior to 19. However, note that the minimum supported version is 17.
 
 <TerminalBlock>
-npm install react-compiler-runtime@beta
+{`npm install react-compiler-runtime@rc`}
 </TerminalBlock>
 
 You should also add the correct `target` to your compiler config, where `target` is the major version of React you are targeting:
@@ -229,7 +197,7 @@ Similarly to apps, it is not necessary to fully compile 100% of your components
 ### Babel {/*usage-with-babel*/}
 
 <TerminalBlock>
-npm install babel-plugin-react-compiler@beta
+{`npm install babel-plugin-react-compiler@rc`}
 </TerminalBlock>
 
 The compiler includes a Babel plugin which you can use in your build pipeline to run the compiler.
@@ -284,7 +252,7 @@ Please refer to the [Next.js docs](https://nextjs.org/docs/app/api-reference/nex
 Install `vite-plugin-babel`, and add the compiler's Babel plugin to it:
 
 <TerminalBlock>
-npm install vite-plugin-babel
+{`npm install vite-plugin-babel`}
 </TerminalBlock>
 
 ```js {2,14}
diff --git a/src/content/learn/removing-effect-dependencies.md b/src/content/learn/removing-effect-dependencies.md
index 9a871c6c3..9a848862a 100644
--- a/src/content/learn/removing-effect-dependencies.md
+++ b/src/content/learn/removing-effect-dependencies.md
@@ -1241,7 +1241,7 @@ export default function Timer() {
 
 </Sandpack>
 
-Instead of reading `count` inside the Effect, you pass a `c => c + 1` instruction ("increment this number!") to React. React will apply it on the next render. And since you don't need to read the value of `count` inside your Effect anymore, so you can keep your Effect's dependencies empty (`[]`). This prevents your Effect from re-creating the interval on every tick.
+Instead of reading `count` inside the Effect, you pass a `c => c + 1` instruction ("increment this number!") to React. React will apply it on the next render. And since you don't need to read the value of `count` inside your Effect anymore, you can keep your Effect's dependencies empty (`[]`). This prevents your Effect from re-creating the interval on every tick.
 
 </Solution>
 
diff --git a/src/content/learn/reusing-logic-with-custom-hooks.md b/src/content/learn/reusing-logic-with-custom-hooks.md
index 67de5e97f..b6562e2df 100644
--- a/src/content/learn/reusing-logic-with-custom-hooks.md
+++ b/src/content/learn/reusing-logic-with-custom-hooks.md
@@ -820,7 +820,7 @@ export default function ChatRoom({ roomId }) {
   // ...
 ```
 
-and pass it as an input to another Hook:
+and passing it as an input to another Hook:
 
 ```js {6}
 export default function ChatRoom({ roomId }) {
@@ -1333,7 +1333,7 @@ export function useOnlineStatus() {
 
 In the above example, `useOnlineStatus` is implemented with a pair of [`useState`](/reference/react/useState) and [`useEffect`.](/reference/react/useEffect) However, this isn't the best possible solution. There is a number of edge cases it doesn't consider. For example, it assumes that when the component mounts, `isOnline` is already `true`, but this may be wrong if the network already went offline. You can use the browser [`navigator.onLine`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine) API to check for that, but using it directly would not work on the server for generating the initial HTML. In short, this code could be improved.
 
-Luckily, React 18 includes a dedicated API called [`useSyncExternalStore`](/reference/react/useSyncExternalStore) which takes care of all of these problems for you. Here is how your `useOnlineStatus` Hook, rewritten to take advantage of this new API:
+React includes a dedicated API called [`useSyncExternalStore`](/reference/react/useSyncExternalStore) which takes care of all of these problems for you. Here is your `useOnlineStatus` Hook, rewritten to take advantage of this new API:
 
 <Sandpack>
 
@@ -2081,7 +2081,6 @@ Write `useInterval` in the `useInterval.js` file and import it into the `useCoun
 <Sandpack>
 
 ```js
-import { useState } from 'react';
 import { useCounter } from './useCounter.js';
 
 export default function Counter() {
diff --git a/src/content/learn/separating-events-from-effects.md b/src/content/learn/separating-events-from-effects.md
index 21276c287..03223183b 100644
--- a/src/content/learn/separating-events-from-effects.md
+++ b/src/content/learn/separating-events-from-effects.md
@@ -439,7 +439,7 @@ function ChatRoom({ roomId, theme }) {
   // ...
 ```
 
-This solves the problem. Note that you had to *remove* `onConnected` from the list of your Effect's dependencies. **Effect Events are not reactive and must be omitted from dependencies.**
+This solves the problem. Note that you had to *remove* `theme` from the list of your Effect's dependencies, because it's no longer used in the Effect. You also don't need to *add* `onConnected` to it, because **Effect Events are not reactive and must be omitted from dependencies.**
 
 Verify that the new behavior works as you would expect:
 
@@ -973,6 +973,23 @@ To fix this code, it's enough to follow the rules.
 
 <Sandpack>
 
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+
 ```js
 import { useState, useEffect } from 'react';
 
@@ -1026,6 +1043,22 @@ If you remove the suppression comment, React will tell you that this Effect's co
 
 <Sandpack>
 
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
 ```js
 import { useState, useEffect } from 'react';
 
diff --git a/src/content/learn/tutorial-tic-tac-toe.md b/src/content/learn/tutorial-tic-tac-toe.md
index 1c61d180a..c80c7f5fe 100644
--- a/src/content/learn/tutorial-tic-tac-toe.md
+++ b/src/content/learn/tutorial-tic-tac-toe.md
@@ -2247,7 +2247,7 @@ body {
 
 </Sandpack>
 
-As you iterate through `history` array inside the function you passed to `map`, the `squares` argument goes through each element of `history`, and the `move` argument goes through each array index: `0`, `1`, `2`, …. (In most cases, you'd need the actual array elements, but to render a list of moves you will only need indexes.)
+As you iterate through the `history` array inside the function you passed to `map`, the `squares` argument goes through each element of `history`, and the `move` argument goes through each array index: `0`, `1`, `2`, …. (In most cases, you'd need the actual array elements, but to render a list of moves you will only need indexes.)
 
 For each move in the tic-tac-toe game's history, you create a list item `<li>` which contains a button `<button>`. The button has an `onClick` handler which calls a function called `jumpTo` (that you haven't implemented yet).
 
diff --git a/src/content/reference/react-dom/client/createRoot.md b/src/content/reference/react-dom/client/createRoot.md
index 0a3933949..adc6a8d37 100644
--- a/src/content/reference/react-dom/client/createRoot.md
+++ b/src/content/reference/react-dom/client/createRoot.md
@@ -90,6 +90,15 @@ React will display `<App />` in the `root`, and take over managing the DOM insid
 
 * If you call `render` on the same root more than once, React will update the DOM as necessary to reflect the latest JSX you passed. React will decide which parts of the DOM can be reused and which need to be recreated by ["matching it up"](/learn/preserving-and-resetting-state) with the previously rendered tree. Calling `render` on the same root again is similar to calling the [`set` function](/reference/react/useState#setstate) on the root component: React avoids unnecessary DOM updates.
 
+* Although rendering is synchronous once it starts, `root.render(...)` is not. This means code after `root.render()` may run before any effects (`useLayoutEffect`, `useEffect`) of that specific render are fired. This is usually fine and rarely needs adjustment. In rare cases where effect timing matters, you can wrap `root.render(...)` in [`flushSync`](https://react.dev/reference/react-dom/client/flushSync) to ensure the initial render runs fully synchronously.
+  
+  ```js
+  const root = createRoot(document.getElementById('root'));
+  root.render(<App />);
+  // 🚩 The HTML will not include the rendered <App /> yet:
+  console.log(document.body.innerHTML);
+  ```
+
 ---
 
 ### `root.unmount()` {/*root-unmount*/}
diff --git a/src/content/reference/react-dom/client/hydrateRoot.md b/src/content/reference/react-dom/client/hydrateRoot.md
index 99190b03a..b74e2c38e 100644
--- a/src/content/reference/react-dom/client/hydrateRoot.md
+++ b/src/content/reference/react-dom/client/hydrateRoot.md
@@ -378,12 +378,13 @@ It is uncommon to call [`root.render`](#root-render) on a hydrated root. Usually
 
 By default, React will log all errors to the console. To implement your own error reporting, you can provide the optional error handler root options `onUncaughtError`, `onCaughtError` and `onRecoverableError`:
 
-```js [[1, 6, "onCaughtError"], [2, 6, "error", 1], [3, 6, "errorInfo"], [4, 10, "componentStack", 15]]
+```js [[1, 7, "onCaughtError"], [2, 7, "error", 1], [3, 7, "errorInfo"], [4, 11, "componentStack", 15]]
 import { hydrateRoot } from "react-dom/client";
+import App from "./App.js";
 import { reportCaughtError } from "./reportError";
 
 const container = document.getElementById("root");
-const root = hydrateRoot(container, {
+const root = hydrateRoot(container, <App />, {
   onCaughtError: (error, errorInfo) => {
     if (error.message !== "Known error") {
       reportCaughtError({
diff --git a/src/content/reference/react-dom/hooks/index.md b/src/content/reference/react-dom/hooks/index.md
index 73eefae75..5dfb07d82 100644
--- a/src/content/reference/react-dom/hooks/index.md
+++ b/src/content/reference/react-dom/hooks/index.md
@@ -14,7 +14,7 @@ The `react-dom` package contains Hooks that are only supported for web applicati
 
 *Forms* let you create interactive controls for submitting information.  To manage forms in your components, use one of these Hooks:
 
-* [`useFormStatus`](/reference/react-dom/hooks/useFormStatus) allows you to make updates to the UI based on the status of the a form.
+* [`useFormStatus`](/reference/react-dom/hooks/useFormStatus) allows you to make updates to the UI based on the status of a form.
 
 ```js
 function Form({ action }) {
diff --git a/src/content/reference/react-dom/preinit.md b/src/content/reference/react-dom/preinit.md
index 0ecd1972d..117fccac8 100644
--- a/src/content/reference/react-dom/preinit.md
+++ b/src/content/reference/react-dom/preinit.md
@@ -48,7 +48,7 @@ The `preinit` function provides the browser with a hint that it should start dow
 * `options`: an object. It contains the following properties:
   *  `as`: a required string. The type of resource. Its possible values are `script` and `style`.
   * `precedence`: a string. Required with stylesheets. Says where to insert the stylesheet relative to others. Stylesheets with higher precedence can override those with lower precedence. The possible values are `reset`, `low`, `medium`, `high`. 
-  *  `crossOrigin`: a string. The [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin) to use. Its possible values are `anonymous` and `use-credentials`. It is required when `as` is set to `"fetch"`.
+  *  `crossOrigin`: a string. The [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin) to use. Its possible values are `anonymous` and `use-credentials`.
   *  `integrity`: a string. A cryptographic hash of the resource, to [verify its authenticity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity).
   *  `nonce`: a string. A cryptographic [nonce to allow the resource](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce) when using a strict Content Security Policy. 
   *  `fetchPriority`: a string. Suggests a relative priority for fetching the resource. The possible values are `auto` (the default), `high`, and `low`.
diff --git a/src/content/reference/react-dom/static/prerender.md b/src/content/reference/react-dom/static/prerender.md
index f1ef38b44..aac6d96b5 100644
--- a/src/content/reference/react-dom/static/prerender.md
+++ b/src/content/reference/react-dom/static/prerender.md
@@ -57,7 +57,7 @@ On the client, call [`hydrateRoot`](/reference/react-dom/client/hydrateRoot) to
   * **optional** `namespaceURI`: A string with the root [namespace URI](https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS#important_namespace_uris) for the stream. Defaults to regular HTML. Pass `'http://www.w3.org/2000/svg'` for SVG or `'http://www.w3.org/1998/Math/MathML'` for MathML.
   * **optional** `onError`: A callback that fires whenever there is a server error, whether [recoverable](/reference/react-dom/server/renderToReadableStream#recovering-from-errors-outside-the-shell) or [not.](/reference/react-dom/server/renderToReadableStream#recovering-from-errors-inside-the-shell) By default, this only calls `console.error`. If you override it to [log crash reports,](/reference/react-dom/server/renderToReadableStream#logging-crashes-on-the-server) make sure that you still call `console.error`. You can also use it to [adjust the status code](/reference/react-dom/server/renderToReadableStream#setting-the-status-code) before the shell is emitted.
   * **optional** `progressiveChunkSize`: The number of bytes in a chunk. [Read more about the default heuristic.](https://github.com/facebook/react/blob/14c2be8dac2d5482fda8a0906a31d239df8551fc/packages/react-server/src/ReactFizzServer.js#L210-L225)
-  * **optional** `signal`: An [abort signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that lets you [abort server rendering](/reference/react-dom/server/renderToReadableStream#aborting-server-rendering) and render the rest on the client.
+  * **optional** `signal`: An [abort signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that lets you [abort prerendering](#aborting-prerendering) and render the rest on the client.
 
 #### Returns {/*returns*/}
 
@@ -66,7 +66,9 @@ On the client, call [`hydrateRoot`](/reference/react-dom/client/hydrateRoot) to
   - `prelude`: a [Web Stream](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) of HTML. You can use this stream to send a response in chunks, or you can read the entire stream into a string.
 - If rendering fails, the Promise will be rejected. [Use this to output a fallback shell.](/reference/react-dom/server/renderToReadableStream#recovering-from-errors-inside-the-shell)
 
+#### Caveats {/*caveats*/}
 
+`nonce` is not an available option when prerendering. Nonces must be unique per request and if you use nonces to secure your application with [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) it would be inappropriate and insecure to include the a nonce value in the prerender itself.
 
 
 <Note>
@@ -287,6 +289,30 @@ Suspense-enabled data fetching without the use of an opinionated framework is no
 
 ---
 
+### Aborting prerendering {/*aborting-prerendering*/}
+
+You can force the prerender to "give up" after a timeout:
+
+```js {2-5,11}
+async function renderToString() {
+  const controller = new AbortController();
+  setTimeout(() => {
+    controller.abort()
+  }, 10000);
+
+  try {
+    // the prelude will contain all the HTML that was prerendered
+    // before the controller aborted.
+    const {prelude} = await prerender(<App />, {
+      signal: controller.signal,
+    });
+    //...
+```
+
+Any Suspense boundaries with incomplete children will be included in the prelude in the fallback state.
+
+---
+
 ## Troubleshooting {/*troubleshooting*/}
 
 ### My stream doesn't start until the entire app is rendered {/*my-stream-doesnt-start-until-the-entire-app-is-rendered*/}
diff --git a/src/content/reference/react-dom/static/prerenderToNodeStream.md b/src/content/reference/react-dom/static/prerenderToNodeStream.md
index b5bb60eaf..fb8073ef0 100644
--- a/src/content/reference/react-dom/static/prerenderToNodeStream.md
+++ b/src/content/reference/react-dom/static/prerenderToNodeStream.md
@@ -58,7 +58,7 @@ On the client, call [`hydrateRoot`](/reference/react-dom/client/hydrateRoot) to
   * **optional** `namespaceURI`: A string with the root [namespace URI](https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS#important_namespace_uris) for the stream. Defaults to regular HTML. Pass `'http://www.w3.org/2000/svg'` for SVG or `'http://www.w3.org/1998/Math/MathML'` for MathML.
   * **optional** `onError`: A callback that fires whenever there is a server error, whether [recoverable](/reference/react-dom/server/renderToPipeableStream#recovering-from-errors-outside-the-shell) or [not.](/reference/react-dom/server/renderToPipeableStream#recovering-from-errors-inside-the-shell) By default, this only calls `console.error`. If you override it to [log crash reports,](/reference/react-dom/server/renderToPipeableStream#logging-crashes-on-the-server) make sure that you still call `console.error`. You can also use it to [adjust the status code](/reference/react-dom/server/renderToPipeableStream#setting-the-status-code) before the shell is emitted.
   * **optional** `progressiveChunkSize`: The number of bytes in a chunk. [Read more about the default heuristic.](https://github.com/facebook/react/blob/14c2be8dac2d5482fda8a0906a31d239df8551fc/packages/react-server/src/ReactFizzServer.js#L210-L225)
-  * **optional** `signal`: An [abort signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that lets you [abort server rendering](/reference/react-dom/server/renderToPipeableStream#aborting-server-rendering) and render the rest on the client.
+  * **optional** `signal`: An [abort signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that lets you [abort prerendering](#aborting-prerendering) and render the rest on the client.
 
 #### Returns {/*returns*/}
 
@@ -67,6 +67,10 @@ On the client, call [`hydrateRoot`](/reference/react-dom/client/hydrateRoot) to
   - `prelude`: a [Node.js Stream.](https://nodejs.org/api/stream.html) of HTML. You can use this stream to send a response in chunks, or you can read the entire stream into a string.
 - If rendering fails, the Promise will be rejected. [Use this to output a fallback shell.](/reference/react-dom/server/renderToPipeableStream#recovering-from-errors-inside-the-shell)
 
+#### Caveats {/*caveats*/}
+
+`nonce` is not an available option when prerendering. Nonces must be unique per request and if you use nonces to secure your application with [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) it would be inappropriate and insecure to include the a nonce value in the prerender itself.
+
 <Note>
 
 ### When should I use `prerenderToNodeStream`? {/*when-to-use-prerender*/}
@@ -285,6 +289,30 @@ Suspense-enabled data fetching without the use of an opinionated framework is no
 
 ---
 
+### Aborting prerendering {/*aborting-prerendering*/}
+
+You can force the prerender to "give up" after a timeout:
+
+```js {2-5,11}
+async function renderToString() {
+  const controller = new AbortController();
+  setTimeout(() => {
+    controller.abort()
+  }, 10000);
+
+  try {
+    // the prelude will contain all the HTML that was prerendered
+    // before the controller aborted.
+    const {prelude} = await prerenderToNodeStream(<App />, {
+      signal: controller.signal,
+    });
+    //...
+```
+
+Any Suspense boundaries with incomplete children will be included in the prelude in the fallback state.
+
+---
+
 ## Troubleshooting {/*troubleshooting*/}
 
 ### My stream doesn't start until the entire app is rendered {/*my-stream-doesnt-start-until-the-entire-app-is-rendered*/}
diff --git a/src/content/reference/react/Activity.md b/src/content/reference/react/Activity.md
new file mode 100644
index 000000000..8b103938e
--- /dev/null
+++ b/src/content/reference/react/Activity.md
@@ -0,0 +1,1161 @@
+---
+title: <Activity>
+version: experimental
+---
+
+<Experimental>
+
+**This API is experimental and is not available in a stable version of React yet.**
+
+You can try it by upgrading React packages to the most recent experimental version:
+
+- `react@experimental`
+- `react-dom@experimental`
+- `eslint-plugin-react-hooks@experimental`
+
+Experimental versions of React may contain bugs. Don't use them in production.
+
+</Experimental>
+
+<Intro>
+
+`<Activity>` lets you hide and show part of the UI.
+
+
+```js
+<Activity mode={mode}>
+  <Page />
+</Activity>
+```
+
+</Intro>
+
+<InlineToc />
+
+---
+
+## Reference {/*reference*/}
+
+### `<Activity>` {/*activity*/}
+
+Wrap a part of the UI in `<Activity>` to manage its visibility state:
+
+```js
+import {unstable_Activity as Activity} from 'react';
+
+<Activity mode={isVisible ? 'visible' : 'hidden'}>
+  <Page />
+</Activity>
+```
+
+When "hidden", the `children` of `<Activity />` are not visible on the page. If a new `<Activity>` mounts as "hidden" then it pre-renders the content at lower priority without blocking the visible content on the page, but it does not mount by creating Effects. When a "visible" Activity switches to "hidden" it conceptually unmounts by destroying all the Effects, but saves its state. This allows fast switching between "visible" and "hidden" states without recreating the state for a "hidden" Activity.
+
+In the future, "hidden" Activities may automatically destroy state based on resources like memory.
+
+#### Props {/*props*/}
+
+* `children`: The actual UI you intend to render.
+* **optional** `mode`: Either "visible" or "hidden". Defaults to "visible". When "hidden", updates to the children are deferred to lower priority. The component will not create Effects until the Activity is switched to "visible". If a "visible" Activity switches to "hidden", the Effects will be destroyed. 
+
+#### Caveats {/*caveats*/}
+
+- While hidden, the `children` of `<Activity>` are hidden on the page. 
+- `<Activity>` will unmount all Effects when switching from "visible" to "hidden" without destroying React or DOM state. This means Effects that are expected to run only once on mount will run again when switching from "hidden" to "visible". Conceptually, "hidden" Activities are unmounted, but they are not destroyed either. We recommend using [`<StrictMode>`](/reference/react/StrictMode) to catch any unexpected side-effects from this behavior.
+- When used with `<ViewTransition>`, hidden activities that reveal in a transition will activate an "enter" animation. Visible Activities hidden in a transition will activate an "exit" animation.
+- Parts of the UI wrapped in `<Activity mode="hidden">` are not included in the SSR response.
+- Parts of the UI wrapped in `<Activity mode="visible">` will hydrate at a lower priority than other content.
+
+---
+
+## Usage {/*usage*/}
+
+### Pre-render part of the UI {/*pre-render-part-of-the-ui*/}
+
+You can pre-render part of the UI using `<Activity mode="hidden">`:
+
+```js
+<Activity mode={tab === "posts" ? "visible" : "hidden"}>
+  <PostsTab />
+</Activity>
+```
+
+When an Activity is rendered with `mode="hidden"`, the `children` are not visible on the page, but are rendered at lower priority than the visible content on the page. 
+
+When the `mode` later switches to "visible", the pre-rendered children will mount and become visible. This can be used to prepare parts of the UI the user is likely to interact with next to reduce loading times.
+
+In the following example from [`useTransition`](/reference/react/useTransition#preventing-unwanted-loading-indicators), the `PostsTab` component fetches some data using `use`. When you click the “Posts” tab, the `PostsTab` component suspends, causing the button loading state to appear:
+
+<Sandpack>
+
+```js
+import { Suspense, useState } from 'react';
+import TabButton from './TabButton.js';
+import AboutTab from './AboutTab.js';
+import PostsTab from './PostsTab.js';
+import ContactTab from './ContactTab.js';
+
+export default function TabContainer() {
+  const [tab, setTab] = useState('about');
+  return (
+    <Suspense fallback={<h1>🌀 Loading...</h1>}>
+      <TabButton
+        isActive={tab === 'about'}
+        action={() => setTab('about')}
+      >
+        About
+      </TabButton>
+      <TabButton
+        isActive={tab === 'posts'}
+        action={() => setTab('posts')}
+      >
+        Posts
+      </TabButton>
+      <TabButton
+        isActive={tab === 'contact'}
+        action={() => setTab('contact')}
+      >
+        Contact
+      </TabButton>
+      <hr />
+      {tab === 'about' && <AboutTab />}
+      {tab === 'posts' && <PostsTab />}
+      {tab === 'contact' && <ContactTab />}
+    </Suspense>
+  );
+}
+```
+
+
+```js src/TabButton.js active
+import { useTransition } from 'react';
+
+export default function TabButton({ action, children, isActive }) {
+  const [isPending, startTransition] = useTransition();
+  if (isActive) {
+    return <b>{children}</b>
+  }
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  return (
+    <button onClick={() => {
+      startTransition(() => {
+        action();
+      });
+    }}>
+      {children}
+    </button>
+  );
+}
+```
+
+```js src/AboutTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function AboutTab() {
+  return (
+    <ViewTransition>
+      <p>Welcome to my profile!</p>
+    </ViewTransition>
+  );
+}
+```
+
+```js src/PostsTab.js hidden
+import {use, unstable_ViewTransition as ViewTransition} from 'react';
+import { fetchData } from './data.js';
+
+function PostsTab() {
+  const posts = use(fetchData('/posts'));
+  return (
+    <ViewTransition>
+    <ul className="items">
+      {posts.map(post =>
+        <Post key={post.id} title={post.title} />
+      )}
+    </ul>
+      </ViewTransition>
+  );
+}
+
+function Post({ title }) {
+  return (
+    <li className="item">
+      {title}
+    </li>
+  );
+}
+
+export default PostsTab;
+```
+
+```js src/ContactTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function ContactTab() {
+  return (
+    <ViewTransition>
+      <p>
+        Send me a message!
+      </p>
+      <textarea />
+      <p>
+        You can find me online here:
+      </p>
+      <ul>
+        <li>admin@mysite.com</li>
+        <li>+123456789</li>
+      </ul>
+    </ViewTransition>
+  );
+}
+```
+
+
+```js src/data.js hidden
+// Note: the way you would do data fetching depends on
+// the framework that you use together with Suspense.
+// Normally, the caching logic would be inside a framework.
+
+let cache = new Map();
+
+export function fetchData(url) {
+  if (!cache.has(url)) {
+    cache.set(url, getData(url));
+  }
+  return cache.get(url);
+}
+
+async function getData(url) {
+  if (url.startsWith('/posts')) {
+    return await getPosts();
+  } else {
+    throw Error('Not implemented');
+  }
+}
+
+async function getPosts() {
+  // Add a fake delay to make waiting noticeable.
+  await new Promise(resolve => {
+    setTimeout(resolve, 1000);
+  });
+  let posts = [];
+  for (let i = 0; i < 10; i++) {
+    posts.push({
+      id: i,
+      title: 'Post #' + (i + 1)
+    });
+  }
+  return posts;
+}
+```
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+.pending { color: #777; }
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest",
+    "toastify-js": "1.12.0"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+</Sandpack>
+
+In this example, the user needs to wait for the posts to load when clicking on the "Posts" tab.
+
+We can reduce the delay for the "Posts" tab by pre-rendering the inactive Tabs with a hidden `<Activity>`: 
+
+<Sandpack>
+
+```js
+import { Suspense, useState, unstable_Activity as Activity } from "react";
+import TabButton from "./TabButton.js";
+import AboutTab from "./AboutTab.js";
+import PostsTab from "./PostsTab.js";
+import ContactTab from "./ContactTab.js";
+
+export default function TabContainer() {
+  const [tab, setTab] = useState("about");
+  return (
+    <Suspense fallback={<h1>🌀 Loading...</h1>}>
+      <TabButton isActive={tab === "about"} action={() => setTab("about")}>
+        About
+      </TabButton>
+      <TabButton isActive={tab === "posts"} action={() => setTab("posts")}>
+        Posts
+      </TabButton>
+      <TabButton isActive={tab === "contact"} action={() => setTab("contact")}>
+        Contact
+      </TabButton>
+      <hr />
+      <Activity mode={tab === "about" ? "visible" : "hidden"}>
+        <AboutTab />
+      </Activity>
+      <Activity mode={tab === "posts" ? "visible" : "hidden"}>
+        <PostsTab />
+      </Activity>
+      <Activity mode={tab === "contact" ? "visible" : "hidden"}>
+        <ContactTab />
+      </Activity>
+    </Suspense>
+  );
+}
+```
+
+
+```js src/TabButton.js active
+import { useTransition } from 'react';
+
+export default function TabButton({ action, children, isActive }) {
+  const [isPending, startTransition] = useTransition();
+  if (isActive) {
+    return <b>{children}</b>
+  }
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  return (
+    <button onClick={() => {
+      startTransition(() => {
+        action();
+      });
+    }}>
+      {children}
+    </button>
+  );
+}
+```
+
+```js src/AboutTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function AboutTab() {
+  return (
+    <ViewTransition>
+      <p>Welcome to my profile!</p>
+    </ViewTransition>
+  );
+}
+```
+
+```js src/PostsTab.js hidden
+import {use, unstable_ViewTransition as ViewTransition} from 'react';
+import { fetchData } from './data.js';
+
+function PostsTab() {
+  const posts = use(fetchData('/posts'));
+  return (
+    <ViewTransition>
+    <ul className="items">
+      {posts.map(post =>
+        <Post key={post.id} title={post.title} />
+      )}
+    </ul>
+      </ViewTransition>
+  );
+}
+
+function Post({ title }) {
+  return (
+    <li className="item">
+      {title}
+    </li>
+  );
+}
+
+export default PostsTab;
+```
+
+```js src/ContactTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function ContactTab() {
+  return (
+    <ViewTransition>
+      <p>
+        Send me a message!
+      </p>
+      <textarea />
+      <p>
+        You can find me online here:
+      </p>
+      <ul>
+        <li>admin@mysite.com</li>
+        <li>+123456789</li>
+      </ul>
+    </ViewTransition>
+  );
+}
+```
+
+
+```js src/data.js hidden
+// Note: the way you would do data fetching depends on
+// the framework that you use together with Suspense.
+// Normally, the caching logic would be inside a framework.
+
+let cache = new Map();
+
+export function fetchData(url) {
+  if (!cache.has(url)) {
+    cache.set(url, getData(url));
+  }
+  return cache.get(url);
+}
+
+async function getData(url) {
+  if (url.startsWith('/posts')) {
+    return await getPosts();
+  } else {
+    throw Error('Not implemented');
+  }
+}
+
+async function getPosts() {
+  // Add a fake delay to make waiting noticeable.
+  await new Promise(resolve => {
+    setTimeout(resolve, 1000);
+  });
+  let posts = [];
+  for (let i = 0; i < 10; i++) {
+    posts.push({
+      id: i,
+      title: 'Post #' + (i + 1)
+    });
+  }
+  return posts;
+}
+```
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+.pending { color: #777; }
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest",
+    "toastify-js": "1.12.0"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+</Sandpack>
+
+---
+
+### Keeping state for part of the UI {/*keeping-state-for-part-of-the-ui*/}
+
+
+You can keep state for parts of the UI by switching `<Activity>` from "visible" to "hidden":
+
+```js
+<Activity mode={tab === "posts" ? "visible" : "hidden"}>
+  <PostsTab />
+</Activity>
+```
+
+When an Activity switches from `mode="visible"` to "hidden", the `children` will become hidden on the page, and unmount by destroying all Effects, but will keep their React and DOM state.
+
+When the `mode` later switches to "visible", the saved state will be re-used when mounting the children by creating all the Effects. This can be used to keep state in parts of the UI the user is likely to interact with again to maintain DOM or React state.
+
+In the following example from [`useTransition`](/reference/react/useTransition#preventing-unwanted-loading-indicators), the `ContactTab` includes a `<textarea>` with a draft message to send. If you enter some text and change to a different tab, then when you click the “Contact” tab again, the draft message is lost:
+
+
+<Sandpack>
+
+```js
+import { Suspense, useState } from 'react';
+import TabButton from './TabButton.js';
+import AboutTab from './AboutTab.js';
+import PostsTab from './PostsTab.js';
+import ContactTab from './ContactTab.js';
+
+export default function TabContainer() {
+  const [tab, setTab] = useState('contact');
+  return (
+    <Suspense fallback={<h1>🌀 Loading...</h1>}>
+      <TabButton
+        isActive={tab === 'about'}
+        action={() => setTab('about')}
+      >
+        About
+      </TabButton>
+      <TabButton
+        isActive={tab === 'posts'}
+        action={() => setTab('posts')}
+      >
+        Posts
+      </TabButton>
+      <TabButton
+        isActive={tab === 'contact'}
+        action={() => setTab('contact')}
+      >
+        Contact
+      </TabButton>
+      <hr />
+      {tab === 'about' && <AboutTab />}
+      {tab === 'posts' && <PostsTab />}
+      {tab === 'contact' && <ContactTab />}
+    </Suspense>
+  );
+}
+```
+
+
+```js src/TabButton.js active
+import { useTransition } from 'react';
+
+export default function TabButton({ action, children, isActive }) {
+  const [isPending, startTransition] = useTransition();
+  if (isActive) {
+    return <b>{children}</b>
+  }
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  return (
+    <button onClick={() => {
+      startTransition(() => {
+        action();
+      });
+    }}>
+      {children}
+    </button>
+  );
+}
+```
+
+```js src/AboutTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function AboutTab() {
+  return (
+    <ViewTransition>
+      <p>Welcome to my profile!</p>
+    </ViewTransition>
+  );
+}
+```
+
+```js src/PostsTab.js hidden
+import {use, unstable_ViewTransition as ViewTransition} from 'react';
+import { fetchData } from './data.js';
+
+function PostsTab() {
+  const posts = use(fetchData('/posts'));
+  return (
+    <ViewTransition>
+    <ul className="items">
+      {posts.map(post =>
+        <Post key={post.id} title={post.title} />
+      )}
+    </ul>
+      </ViewTransition>
+  );
+}
+
+function Post({ title }) {
+  return (
+    <li className="item">
+      {title}
+    </li>
+  );
+}
+
+export default PostsTab;
+```
+
+```js src/ContactTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function ContactTab() {
+  return (
+    <ViewTransition>
+      <p>
+        Send me a message!
+      </p>
+      <textarea />
+      <p>
+        You can find me online here:
+      </p>
+      <ul>
+        <li>admin@mysite.com</li>
+        <li>+123456789</li>
+      </ul>
+    </ViewTransition>
+  );
+}
+```
+
+
+```js src/data.js hidden
+// Note: the way you would do data fetching depends on
+// the framework that you use together with Suspense.
+// Normally, the caching logic would be inside a framework.
+
+let cache = new Map();
+
+export function fetchData(url) {
+  if (!cache.has(url)) {
+    cache.set(url, getData(url));
+  }
+  return cache.get(url);
+}
+
+async function getData(url) {
+  if (url.startsWith('/posts')) {
+    return await getPosts();
+  } else {
+    throw Error('Not implemented');
+  }
+}
+
+async function getPosts() {
+  // Add a fake delay to make waiting noticeable.
+  await new Promise(resolve => {
+    setTimeout(resolve, 1000);
+  });
+  let posts = [];
+  for (let i = 0; i < 10; i++) {
+    posts.push({
+      id: i,
+      title: 'Post #' + (i + 1)
+    });
+  }
+  return posts;
+}
+```
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+.pending { color: #777; }
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest",
+    "toastify-js": "1.12.0"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+</Sandpack>
+
+This results in losing DOM state the user has input. We can keep the state for the Contact tab by hiding the inactive Tabs with `<Activity>`:
+
+
+<Sandpack>
+
+```js
+import { Suspense, useState, unstable_Activity as Activity } from "react";
+import TabButton from "./TabButton.js";
+import AboutTab from "./AboutTab.js";
+import PostsTab from "./PostsTab.js";
+import ContactTab from "./ContactTab.js";
+
+export default function TabContainer() {
+  const [tab, setTab] = useState("about");
+  return (
+    <Suspense fallback={<h1>🌀 Loading...</h1>}>
+      <TabButton isActive={tab === "about"} action={() => setTab("about")}>
+        About
+      </TabButton>
+      <TabButton isActive={tab === "posts"} action={() => setTab("posts")}>
+        Posts
+      </TabButton>
+      <TabButton isActive={tab === "contact"} action={() => setTab("contact")}>
+        Contact
+      </TabButton>
+      <hr />
+      <Activity mode={tab === "about" ? "visible" : "hidden"}>
+        <AboutTab />
+      </Activity>
+      <Activity mode={tab === "posts" ? "visible" : "hidden"}>
+        <PostsTab />
+      </Activity>
+      <Activity mode={tab === "contact" ? "visible" : "hidden"}>
+        <ContactTab />
+      </Activity>
+    </Suspense>
+  );
+}
+```
+
+
+```js src/TabButton.js active
+import { useTransition } from 'react';
+
+export default function TabButton({ action, children, isActive }) {
+  const [isPending, startTransition] = useTransition();
+  if (isActive) {
+    return <b>{children}</b>
+  }
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
+  return (
+    <button onClick={() => {
+      startTransition(() => {
+        action();
+      });
+    }}>
+      {children}
+    </button>
+  );
+}
+```
+
+```js src/AboutTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function AboutTab() {
+  return (
+    <ViewTransition>
+      <p>Welcome to my profile!</p>
+    </ViewTransition>
+  );
+}
+```
+
+```js src/PostsTab.js hidden
+import {use, unstable_ViewTransition as ViewTransition} from 'react';
+import { fetchData } from './data.js';
+
+function PostsTab() {
+  const posts = use(fetchData('/posts'));
+  return (
+    <ViewTransition>
+    <ul className="items">
+      {posts.map(post =>
+        <Post key={post.id} title={post.title} />
+      )}
+    </ul>
+      </ViewTransition>
+  );
+}
+
+function Post({ title }) {
+  return (
+    <li className="item">
+      {title}
+    </li>
+  );
+}
+
+export default PostsTab;
+```
+
+```js src/ContactTab.js hidden
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+export default function ContactTab() {
+  return (
+    <ViewTransition>
+      <p>
+        Send me a message!
+      </p>
+      <textarea />
+      <p>
+        You can find me online here:
+      </p>
+      <ul>
+        <li>admin@mysite.com</li>
+        <li>+123456789</li>
+      </ul>
+    </ViewTransition>
+  );
+}
+```
+
+
+```js src/data.js hidden
+// Note: the way you would do data fetching depends on
+// the framework that you use together with Suspense.
+// Normally, the caching logic would be inside a framework.
+
+let cache = new Map();
+
+export function fetchData(url) {
+  if (!cache.has(url)) {
+    cache.set(url, getData(url));
+  }
+  return cache.get(url);
+}
+
+async function getData(url) {
+  if (url.startsWith('/posts')) {
+    return await getPosts();
+  } else {
+    throw Error('Not implemented');
+  }
+}
+
+async function getPosts() {
+  // Add a fake delay to make waiting noticeable.
+  await new Promise(resolve => {
+    setTimeout(resolve, 1000);
+  });
+  let posts = [];
+  for (let i = 0; i < 10; i++) {
+    posts.push({
+      id: i,
+      title: 'Post #' + (i + 1)
+    });
+  }
+  return posts;
+}
+```
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+.pending { color: #777; }
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest",
+    "toastify-js": "1.12.0"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+</Sandpack>
+
+---
+
+## Troubleshooting {/*troubleshooting*/}
+
+### Effects don't mount when an Activity is hidden {/*effects-dont-mount-when-an-activity-is-hidden*/}
+
+When an `<Activity>` is "hidden", all Effects are unmounted. Conceptually, the component is unmounted, but React saves the state for later. 
+
+This is a feature of Activity because it means subscriptions won't be subscribed for hidden parts of the UI, reducing the amount of work for hidden content. It also means cleanup, such as pausing a video (which would be expected if you unmounted without Activity) will fire. When an Activity switches to "visible", it will mount by creating the Effects, which will subscribe and play the video.
+
+Consider the following example, where a different video is played for each button:
+
+
+<Sandpack>
+
+```js
+import { useState, useRef, useEffect } from 'react';
+import './checker.js';
+
+function VideoPlayer({ src, isPlaying }) {
+  const ref = useRef(null);
+
+  useEffect(() => {
+    const videoRef = ref.current;
+    videoRef.play();
+    
+    return () => {
+      videoRef.pause();
+    }
+  }, []);
+
+  return <video ref={ref} src={src} muted loop playsInline/>;
+}
+
+export default function App() {
+  const [video, setVideo] = useState(1);
+  return (
+    <>
+      <div>
+        <button onClick={() => setVideo(1)}>Big Buck Bunny</button>
+        <button onClick={() => setVideo(2)}>Elephants Dream</button>
+      </div>
+      {video === 1 &&
+        <VideoPlayer key={1}
+          // 'Big Buck Bunny' licensed under CC 3.0 by the Blender foundation. Hosted by archive.org
+          src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4" />
+
+      }
+      {video === 2 && 
+        <VideoPlayer key={2}
+          // 'Elephants Dream' by Orange Open Movie Project Studio, licensed under CC-3.0, hosted by archive.org
+          src="https://archive.org/download/ElephantsDream/ed_1024_512kb.mp4"
+        />
+      }
+    </>
+  );
+}
+```
+
+```js src/checker.js hidden
+let interval = setInterval(() => {
+  const videos = Array.from(document.querySelectorAll('video'));
+  const playing = videos.filter(
+    (v) => !v.paused
+  );
+  if (playing.length > 1) {
+    console.error(`Multiple playing videos: ${playing.length}`);
+  }
+    
+}, 50);
+```
+
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+video { width: 300px; margin-top: 10px; }
+```
+
+</Sandpack>
+
+
+Whenever you change videos and come back, the video re-loads from the beginning. To maintain the state, you may try to render both videos, and hide the inactive video in `display: none`. However, this will cause both videos to play at the same time:
+
+
+<Sandpack>
+
+```js
+import { useState, useRef, useEffect } from 'react';
+import VideoChecker from './checker.js';
+
+function VideoPlayer({ src, isPlaying }) {
+  const ref = useRef(null);
+
+  useEffect(() => {
+    const videoRef = ref.current;
+    videoRef.play();
+    
+    return () => {
+      videoRef.pause();
+    }
+  }, []);
+
+  return <video ref={ref} src={src} muted loop playsInline/>;
+}
+
+export default function App() {
+  const [video, setVideo] = useState(1);
+  return (
+    <>
+      <div>
+        <button onClick={() => setVideo(1)}>Big Buck Bunny</button>
+        <button onClick={() => setVideo(2)}>Elephants Dream</button>
+      </div>
+      <div style={{display: video === 1 ? 'block' : 'none'}}>
+        <VideoPlayer
+          // 'Big Buck Bunny' licensed under CC 3.0 by the Blender foundation. Hosted by archive.org
+          src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4" />
+
+      </div>
+      <div style={{display: video === 2 ? 'block' : 'none'}}>
+        <VideoPlayer
+          // 'Elephants Dream' by Orange Open Movie Project Studio, licensed under CC-3.0, hosted by archive.org
+          src="https://archive.org/download/ElephantsDream/ed_1024_512kb.mp4"
+        />
+      </div>
+      <VideoChecker />
+    </>
+  );
+}
+```
+
+```js src/checker.js hidden
+import {useRef, useEffect} from 'react';
+
+export default function VideoChecker() {
+  const hasLogged = useRef(false);
+
+  useEffect(() => {
+    let interval = setInterval(() => {
+      if (hasLogged.current === false) {
+
+        const videos = Array.from(document.querySelectorAll('video'));
+        const playing = videos.filter(
+          (v) => !v.paused
+        );
+        if (hasLogged.current === false && playing.length > 1) {
+          hasLogged.current = true;
+          console.error(`Multiple playing videos: ${playing.length}`);
+        }
+      }
+
+    }, 50);
+    
+    return () => {
+      hasLogged.current = false;
+      clearInterval(interval);
+    }
+  });
+  
+}
+
+```
+
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+video { width: 300px; margin-top: 10px; }
+```
+
+</Sandpack>
+
+This is similar to what would happen if Activity mounted Effects when hidden. Similarly, if Activity didn't unmount Effects when hiding, the videos would continue to play in the background.
+
+Activity solves this by not creating Effects when first rendered as "hidden" and destroying all Effects when switching from "visible" to "hidden":
+
+
+<Sandpack>
+
+```js
+import { useState, useRef, useEffect, unstable_Activity as Activity } from 'react';
+import VideoChecker from './checker.js';
+
+function VideoPlayer({ src, isPlaying }) {
+  const ref = useRef(null);
+
+  useEffect(() => {
+    const videoRef = ref.current;
+    videoRef.play();
+    
+    return () => {
+      videoRef.pause();
+    }
+  }, []);
+
+  return <video ref={ref} src={src} muted loop playsInline/>;
+}
+
+export default function App() {
+  const [video, setVideo] = useState(1);
+  return (
+    <>
+      <div>
+        <button onClick={() => setVideo(1)}>Big Buck Bunny</button>
+        <button onClick={() => setVideo(2)}>Elephants Dream</button>
+      </div>
+      <Activity mode={video === 1 ? 'visible' : 'hidden'}>
+        <VideoPlayer
+          // 'Big Buck Bunny' licensed under CC 3.0 by the Blender foundation. Hosted by archive.org
+          src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4" />
+      </Activity>
+      <Activity mode={video === 2 ? 'visible' : 'hidden'}>
+        <VideoPlayer
+          // 'Elephants Dream' by Orange Open Movie Project Studio, licensed under CC-3.0, hosted by archive.org
+          src="https://archive.org/download/ElephantsDream/ed_1024_512kb.mp4"
+        />
+      </Activity>
+      <VideoChecker />
+    </>
+  );
+}
+```
+
+```js src/checker.js hidden
+import {useRef, useEffect} from 'react';
+
+export default function VideoChecker() {
+  const hasLogged = useRef(false);
+
+  useEffect(() => {
+    let interval = setInterval(() => {
+      if (hasLogged.current === false) {
+
+        const videos = Array.from(document.querySelectorAll('video'));
+        const playing = videos.filter(
+          (v) => !v.paused
+        );
+        if (hasLogged.current === false && playing.length > 1) {
+          hasLogged.current = true;
+          console.error(`Multiple playing videos: ${playing.length}`);
+        }
+      }
+
+    }, 50);
+    
+    return () => {
+      hasLogged.current = false;
+      clearInterval(interval);
+    }
+  });
+  
+}
+
+```
+
+```css
+body { height: 275px; }
+button { margin-right: 10px }
+b { display: inline-block; margin-right: 10px; }
+video { width: 300px; margin-top: 10px; }
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest",
+    "toastify-js": "1.12.0"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+</Sandpack>
+
+For this reason, it's best to think of Activity conceptually as "unmounting" and "remounting" the component, but saving the React or DOM state for later. 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 [`<StrictMode>`](/reference/react/StrictMode) which will eagerly perform Activity unmounts and mounts to catch any unexpected side-effects. 
+
+### My hidden Activity is not rendered in SSR {/*my-hidden-activity-is-not-rendered-in-ssr*/}
+
+When you use `<Activity mode="hidden">` during server-side rendering, the content of the Activity will not be included in the SSR response. This is because the content is not visible on the page and is not needed for the initial render. If you need to include the content in the SSR response, you can use a different approach like [`useDeferredValue`](/reference/react/useDeferredValue) to defer rendering of the content.
diff --git a/src/content/reference/react/Component.md b/src/content/reference/react/Component.md
index 0821d1593..8e58af8ff 100644
--- a/src/content/reference/react/Component.md
+++ b/src/content/reference/react/Component.md
@@ -1273,7 +1273,7 @@ By default, if your application throws an error during rendering, React will rem
 
 To implement an error boundary component, you need to provide [`static getDerivedStateFromError`](#static-getderivedstatefromerror) which lets you update state in response to an error and display an error message to the user. You can also optionally implement [`componentDidCatch`](#componentdidcatch) to add some extra logic, for example, to log the error to an analytics service.
 
-<CanaryBadge /> With [`captureOwnerStack`](/reference/react/captureOwnerStack) you can include the Owner Stack during development.
+With [`captureOwnerStack`](/reference/react/captureOwnerStack) you can include the Owner Stack during development.
 
 ```js {9-12,14-27}
 import * as React from 'react';
@@ -1298,8 +1298,7 @@ class ErrorBoundary extends React.Component {
       //   in div (created by App)
       //   in App
       info.componentStack,
-      // Only available in react@canary.
-      // Warning: Owner Stack is not available in production.
+      // Warning: `captureOwnerStack` is not available in production.
       React.captureOwnerStack(),
     );
   }
diff --git a/src/content/reference/react/StrictMode.md b/src/content/reference/react/StrictMode.md
index 5b62794bc..1af02ccd1 100644
--- a/src/content/reference/react/StrictMode.md
+++ b/src/content/reference/react/StrictMode.md
@@ -88,7 +88,7 @@ Strict Mode enables the following checks in development:
 
 - Your components will [re-render an extra time](#fixing-bugs-found-by-double-rendering-in-development) to find bugs caused by impure rendering.
 - Your components will [re-run Effects an extra time](#fixing-bugs-found-by-re-running-effects-in-development) to find bugs caused by missing Effect cleanup.
-- Your components will [re-run ref callbacks an extra time](#fixing-bugs-found-by-cleaning-up-and-re-attaching-dom-refs-in-development) to find bugs caused by missing ref cleanup.
+- Your components will [re-run ref callbacks an extra time](#fixing-bugs-found-by-re-running-ref-callbacks-in-development) to find bugs caused by missing ref cleanup.
 - Your components will [be checked for usage of deprecated APIs.](#fixing-deprecation-warnings-enabled-by-strict-mode)
 
 **All of these checks are development-only and do not impact the production build.**
@@ -122,6 +122,12 @@ function App() {
 
 In this example, Strict Mode checks will not run against the `Header` and `Footer` components. However, they will run on `Sidebar` and `Content`, as well as all of the components inside them, no matter how deep.
 
+<Note>
+
+When `StrictMode` is enabled for a part of the app, React will only enable behaviors that are possible in production. For example, if `<StrictMode>` is not enabled at the root of the app, it will not [re-run Effects an extra time](#fixing-bugs-found-by-re-running-effects-in-development) on initial mount, since this would cause child effects to double fire without the parent effects, which cannot happen in production.
+
+</Note>
+
 ---
 
 ### Fixing bugs found by double rendering in development {/*fixing-bugs-found-by-double-rendering-in-development*/}
diff --git a/src/content/reference/react/ViewTransition.md b/src/content/reference/react/ViewTransition.md
new file mode 100644
index 000000000..b0dc7b571
--- /dev/null
+++ b/src/content/reference/react/ViewTransition.md
@@ -0,0 +1,2213 @@
+---
+title: <ViewTransition>
+version: experimental
+---
+
+<Experimental>
+
+**This API is experimental and is not available in a stable version of React yet.**
+
+You can try it by upgrading React packages to the most recent experimental version:
+
+- `react@experimental`
+- `react-dom@experimental`
+- `eslint-plugin-react-hooks@experimental`
+
+Experimental versions of React may contain bugs. Don't use them in production.
+
+</Experimental>
+
+<Intro>
+
+`<ViewTransition>` lets you animate elements that update inside a Transition.
+
+
+```js
+import {unstable_ViewTransition as ViewTransition} from 'react';
+
+<ViewTransition>
+  <div>...</div>
+</ViewTransition>
+```
+
+</Intro>
+
+<InlineToc />
+
+---
+
+## Reference {/*reference*/}
+
+### `<ViewTransition>` {/*viewtransition*/}
+
+Wrap elements in `<ViewTransition>` to animate them when they update inside a [Transition](/reference/react/useTransition). React uses the following heuristics to determine if a View Transition activates for an animation:
+
+- `enter`: If a `ViewTransition` itself gets inserted in this Transition, then this will activate.
+- `exit`: If a `ViewTransition` itself gets deleted in this Transition, then this will activate.
+- `update`: If a `ViewTransition` has any DOM mutations inside it that React is doing (such as a prop changing) or if the `ViewTransition` boundary itself changes size or position due to an immediate sibling. If there are nested` ViewTransition` then the mutation applies to them and not the parent.
+- `share`: If a named `ViewTransition` is inside a deleted subtree and another named `ViewTransition` with the same name is part of an inserted subtree in the same Transition, they form a Shared Element Transition, and it animates from the deleted one to the inserted one.
+
+By default, `<ViewTransition>` animates with a smooth cross-fade (the browser default view transition). You can customize the animation by providing a [View Transition Class](#view-transition-class) to the `<ViewTransition>` component. You can  customize animations for each kind of trigger (see [Styling View Transitions](#styling-view-transitions)).
+
+<DeepDive>
+
+#### How does `<ViewTransition>` work? {/*how-does-viewtransition-work*/}
+
+Under the hood, React applies `view-transition-name` to inline styles of the nearest DOM node nested inside the `<ViewTransition>` component. If there are multiple sibling DOM nodes like `<ViewTransition><div /><div /></ViewTransition>` then React adds a suffix to the name to make each unique but conceptually they're part of the same one. React doesn't apply these eagerly but only at the time that boundary should participate in an animation.
+
+React automatically calls `startViewTransition` itself behind the scenes so you should never do that yourself. In fact, if you have something else on the page running a ViewTransition React will interrupt it. So it's recommended that you use React itself to coordinate these. If you had other ways of trigger ViewTransitions in the past, we recommend that you migrate to the built-in way.
+
+If there are other React ViewTransitions already running then React will wait for them to finish before starting the next one. However, importantly if there are multiple updates happening while the first one is running, those will all be batched into one. If you start A->B. Then in the meantime you get an update to go to C and then D. When the first A->B animation finishes the next one will animate from B->D.
+
+The `getSnapshotBeforeUpdate` life-cycle will be called before `startViewTransition` and some `view-transition-name` will update at the same time.
+
+Then React calls `startViewTransition`. Inside the `updateCallback`, React will:
+
+- Apply its mutations to the DOM and invoke useInsertionEffects.
+- Wait for fonts to load.
+- Call componentDidMount, componentDidUpdate, useLayoutEffect and refs.
+- Wait for any pending Navigation to finish.
+- Then React will measure any changes to the layout to see which boundaries will need to animate.
+
+After the ready Promise of the `startViewTransition` is resolved, React will then revert the `view-transition-name`. Then React will invoke the `onEnter`, `onExit`, `onUpdate` and `onShare` callbacks to allow for manual programmatic control over the Animations. This will be after the built-in default ones have already been computed.
+
+If a `flushSync` happens to get in the middle of this sequence, then React will skip the Transition since it relies on being able to complete synchronously.
+
+After the finished Promise of the `startViewTransition` is resolved, React will then invoke `useEffect`. This prevents those from interfering with the performance of the Animation. However, this is not a guarantee because if another `setState` happens while the Animation is running it'll still have to invoke the `useEffect` earlier to preserve the sequential guarantees.
+
+</DeepDive>
+
+#### Props {/*props*/}
+
+By default, `<ViewTransition>` animates with a smooth cross-fade. You can customize the animation, or specify a shared element transition, with these props:
+
+* **optional** `enter`: A string or object. The [View Transition Class](#view-transition-class) to apply when enter is activated.
+* **optional** `exit`: A string or object. The [View Transition Class](#view-transition-class) to apply when exit is activated.
+* **optional** `update`: A string or object. The [View Transition Class](#view-transition-class) to apply when an update is activated.
+* **optional** `share`: A string or object. The [View Transition Class](#view-transition-class) to apply when a shared element is activated.
+* **optional** `default`: A string or object. The [View Transition Class](#view-transition-class) used when no other matching activation prop is found. 
+* **optional** `name`: A string or object. The name of the View Transition used for shared element transitions. If not provided, React will use a unique name for each View Transition to prevent unexpected animations.
+
+#### Callback {/*events*/}
+
+These callbacks allow you to adjust the animation imperatively using the [animate](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate) APIs:
+
+* **optional** `onEnter`: A function. React calls `onEnter` after an "enter" animation.
+* **optional** `onExit`: A function. React calls `onExit` after an "exit" animation.
+* **optional** `onShare`:  A function. React calls `onShare` after a "share" animation.
+* **optional** `onUpdate`:  A function. React calls `onUpdate` after an "update" animation.
+
+Each callback receives as arguments:
+- `element`: The DOM element that was animated.
+- `types`: The [Transition Types](/reference/react/addTransitionType) included in the animation.
+
+### View Transition Class {/*view-transition-class*/}
+
+The View Transition Class is the CSS class name(s) applied by React during the transition when the ViewTransition activates. It can be a string or an object.
+- `string`: the `class` added on the child elements when activated. If `'none'` is provided, no class will be added.
+- `object`: the class added on the child elements will be the key matching View Transition type added with `addTransitionType`. The object can also specify a `default` to use if no matching type is found.
+
+The value `'none'` can be used to prevent a View Transition from activating for a specific trigger.
+
+### Styling View Transitions {/*styling-view-transitions*/}
+
+<Note>
+
+In many early examples of View Transitions around the web, you'll have seen using a [`view-transition-name`](https://developer.mozilla.org/en-US/docs/Web/CSS/view-transition-name) and then style it using `::view-transition-...(my-name)` selectors. We don't recommend that for styling. Instead, we normally recommend using a View Transition Class instead.
+
+</Note>
+
+To customize the animation for a `<ViewTransition>` you can provide a View Transition Class to one of the activation props. The View Transition Class is a CSS class name that React applies to the child elements when the ViewTransition activates.
+
+For example, to customize an "enter" animation, provide a class name to the `enter` prop:
+
+
+```js
+<ViewTransition enter="slide-in">
+```
+
+When the `<ViewTransition>` activates an "enter" animation, React will add the class name `slide-in`. Then you can refer to this class using [view transition pseudo selectors](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API#pseudo-elements) to build reusable animations:
+
+```css
+::view-transition-group(.slide-in) {
+  
+}
+::view-transition-old(.slide-in) {
+
+}
+::view-transition-new(.slide-in) {
+
+}
+```
+In the future, CSS libraries may add built-in animations using View Transition Classes to make this easier to use.
+
+#### Caveats {/*caveats*/}
+
+- By default, `setState` updates immediately and does not activate `<ViewTransition>`, only updates wrapped in a [Transition](/reference/react/useTransition). You can also use [`<Suspense>`](/reference/react/Suspense) to opt-in to a Transition to [reveal content](/link-to-suspense-below).
+- `<ViewTransition>` creates an image that can be moved around, scaled and cross-faded. Unlike Layout Animations you may have seen in React Native or Motion, this means that not every individual Element inside of it animates its position. This can lead to better performance and a more continuous feeling, smooth animation compared to animating every individual piece. However, it can also lose continuity in things that should be moving by themselves. So you might have to add more `<ViewTransition>` boundaries manually as a result.
+- Many users may prefer not having animations on the page. React doesn't automatically disable animations for this case. We recommend that using the `@media (prefers-reduced-motion)` media query to disable animations or tone them down based on user preference. In the future, CSS libraries may have this built-in to their presets.
+- Currently, `<ViewTransition>` only works in the DOM. We're working on adding support for React Native and other platforms.
+
+---
+
+
+## Usage {/*usage*/}
+
+### Animating an element on enter/exit {/*animating-an-element-on-enter*/}
+
+Enter/Exit Transitions trigger when a `<ViewTransition>` is added or removed by a component in a transition:
+
+```js
+function Child() {
+  return <ViewTransition>Hi</ViewTransition>
+}
+
+function Parent() {
+  const [show, setShow] = useState();
+  if (show) {
+    return <Child />;
+  }
+  return null;
+}
+```
+
+When `setShow` is called, `show` switches to `true` and the `Child` component is rendered. When `setShow` is called inside `startTransition`, and `Child` renders a `ViewTransition` before any other DOM nodes, an `enter` animation is triggered. 
+
+When `show` switches back to `false`, an `exit` animation is triggered.
+
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div
+        className="link"
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition
+} from 'react';
+import {Video} from "./Video";
+import videos from "./data"
+
+function Item() {
+  return (
+    <ViewTransition>
+      <Video video={videos[0]}/>
+    </ViewTransition>
+  );
+}
+
+export default function Component() {
+  const [showItem, setShowItem] = useState(false);
+  return (
+    <>
+      <button
+        onClick={() => {
+          startTransition(() => {
+            setShowItem((prev) => !prev);
+          });
+        }}
+      >{showItem ? '➖' : '➕'}</button>
+
+      {showItem ? <Item /> : null}
+    </>
+  );
+}
+```
+
+```js src/data.js hidden
+export default [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  }
+]
+```
+
+
+```css
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 200px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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);
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.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;
+}
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+<Pitfall>
+
+`<ViewTransition>` only activates if it is placed before any DOM node. If `Child` instead looked like this, no animation would trigger:
+
+```js [3, 5]
+function Component() {
+  return (
+    <div>
+      <ViewTransition>Hi</ViewTransition>
+    </div>
+  );
+}
+```
+
+</Pitfall>
+
+---
+### Animating a shared element {/*animating-a-shared-element*/}
+
+Normally, we don't recommend assigning a name to a `<ViewTransition>` and instead let React assign it an automatic name. The reason you might want to assign a name is to animate between completely different components when one tree unmounts and another tree mounts at the same time. To preserve continuity.
+
+```js
+<ViewTransition name={UNIQUE_NAME}>
+  <Child />
+</ViewTransition>
+```
+
+When one tree unmounts and another mounts, if there's a pair where the same name exists in the unmounting tree and the mounting tree, they trigger the "share" animation on both. It animates from the unmounting side to the mounting side.
+
+Unlike an exit/enter animation this can be deeply inside the deleted/mounted tree. If a `<ViewTransition>` would also be eligible for exit/enter, then the "share" animation takes precedence.
+
+If Transition first unmounts one side and then leads to a `<Suspense>` fallback being shown before eventually the new name being mounted, then no shared element transition happens.
+
+<Sandpack>
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition
+} from "react";
+import {Video, Thumbnail, FullscreenVideo} from "./Video";
+import videos from "./data";
+
+export default function Component() {
+  const [fullscreen, setFullscreen] = useState(false);
+  if (fullscreen) {
+    return <FullscreenVideo
+      video={videos[0]}
+      onExit={() => startTransition(() => setFullscreen(false))}
+    />
+  }
+  return <Video
+    video={videos[0]}
+    onClick={() => startTransition(() => setFullscreen(true))}
+  />
+}
+
+```
+
+```js src/Video.js
+import {unstable_ViewTransition as ViewTransition} from "react";
+
+const THUMBNAIL_NAME = "video-thumbnail"
+
+export function Thumbnail({ video, children }) {
+  return (
+    <ViewTransition name={THUMBNAIL_NAME}>
+      <div
+        aria-hidden="true"
+        tabIndex={-1}
+        className={`thumbnail ${video.image}`}
+      />
+    </ViewTransition>
+  );
+}
+
+export function Video({ video, onClick }) {
+  return (
+    <div className="video">
+      <div className="link" onClick={onClick}>
+        <Thumbnail video={video} />
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function FullscreenVideo({video, onExit}) {
+  return (
+    <div className="fullscreenLayout">
+      <ViewTransition name={THUMBNAIL_NAME}>
+        <div
+          aria-hidden="true"
+          tabIndex={-1}
+          className={`thumbnail ${video.image} fullscreen`}
+        />
+        <button
+          className="close-button"
+          onClick={onExit}
+        >
+          ✖
+        </button>
+      </ViewTransition>
+    </div>
+  );
+}
+```
+
+
+```js src/data.js hidden
+export default [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  }
+]
+```
+
+
+```css
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  height: 300px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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.fullscreen {
+  height: 100%;
+  width: 100%;
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.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;
+}
+.fullscreenLayout {
+  position: relative;
+  height: 100%;
+  width: 100%;
+}
+.close-button {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  color: black;
+}
+@keyframes progress-animation {
+  from {
+    width: 0;
+  }
+  to {
+    width: 100%;
+  }
+}
+```
+
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+
+<Note>
+
+If either the mounted or unmounted side of a pair is outside the viewport, then no pair is formed. This ensures that it doesn't fly in or out of the viewport when something is scrolled. Instead it's treated as a regular enter/exit by itself.
+
+This does not happen if the same Component instance changes position, which triggers an "update". Those animate regardless if one position is outside the viewport.
+
+There's currently a quirk where if a deeply nested unmounted `<ViewTransition>` is inside the viewport but the mounted side is not within the viewport, then the unmounted side animates as its own "exit" animation even if it's deeply nested instead of as part of the parent animation.
+
+</Note>
+
+<Pitfall>
+
+It's important that there's only one thing with the same name mounted at a time in the entire app. Therefore it's important to use unique namespaces for the name to avoid conflicts. To ensure you can do this you might want to add a constant in a separate module that you import.
+
+```js
+export const MY_NAME = "my-globally-unique-name";
+import {MY_NAME} from './shared-name';
+...
+<ViewTransition name={MY_NAME}>
+```
+
+</Pitfall>
+
+
+---
+
+### Animating reorder of items in a list {/*animating-reorder-of-items-in-a-list*/}
+
+
+```js
+items.map(item => <Component key={item.id} item={item} />)
+```
+
+When reordering a list, without updating the content, the "update" animation triggers on each `<ViewTransition>` in the list if they're outside a DOM node. Similar to enter/exit animations.
+
+This means that this will trigger the animation on this `<ViewTransition>`:
+
+```js
+function Component() {
+  return <ViewTransition><div>...</div></ViewTransition>;
+}
+```
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div className="link">
+        <Thumbnail video={video}></Thumbnail>
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition
+} from "react";
+import {Video} from "./Video";
+import videos from "./data";
+
+export default function Component() {
+  const [orderedVideos, setOrderedVideos] = useState(videos);
+  const reorder = () => {
+    startTransition(() => {
+      setOrderedVideos((prev) => {
+        return [...prev.sort(() => Math.random() - 0.5)];
+      });
+    });
+  };
+  return (
+    <>
+      <button onClick={reorder}>🎲</button>
+      <div className="listContainer">
+        {orderedVideos.map((video, i) => {
+          return (
+            <ViewTransition key={video.title}>
+              <Video video={video} />
+            </ViewTransition>
+          );
+        })}
+      </div>
+    </>
+  );
+}
+  
+
+```
+
+```js src/data.js hidden
+export default [
+  {
+    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',
+  }
+]
+```
+
+
+```css
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 150px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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);
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+}
+.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;
+}
+```
+
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+However, this wouldn't animate each individual item:
+
+```js
+function Component() {
+  return <div><ViewTransition>...</ViewTransition></div>;
+}
+```
+Instead, any parent `<ViewTransition>` would cross-fade. If there is no parent `<ViewTransition>` then there's no animation in that case.
+
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div className="link">
+        <Thumbnail video={video}></Thumbnail>
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition
+} from "react";
+import {Video} from "./Video";
+import videos from "./data";
+
+export default function Component() {
+  const [orderedVideos, setOrderedVideos] = useState(videos);
+  const reorder = () => {
+    startTransition(() => {
+      setOrderedVideos((prev) => {
+        return [...prev.sort(() => Math.random() - 0.5)];
+      });
+    });
+  };
+  return (
+    <>
+      <button onClick={reorder}>🎲</button>
+      <ViewTransition>
+        <div className="listContainer">
+          {orderedVideos.map((video, i) => {
+            return <Video video={video} key={video.title} />;
+          })}
+        </div>
+      </ViewTransition>
+    </>
+  );
+}
+  
+
+```
+
+```js src/data.js hidden
+export default [
+  {
+    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',
+  }
+]
+```
+
+
+```css
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 150px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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);
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.video .link {
+  display: flex;
+  flex-direction: row;
+  flex: 1 1 0;
+  gap: 0.125rem;
+  outline-offset: 4px;
+}
+.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;
+}
+```
+
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+This means you might want to avoid wrapper elements in lists where you want to allow the Component to control its own reorder animation:
+
+```
+items.map(item => <div><Component key={item.id} item={item} /></div>)
+```
+
+The above rule also applies if one of the items updates to resize, which then causes the siblings to resize, it'll also animate its sibling `<ViewTransition>` but only if they're immediate siblings.
+
+This means that during an update, which causes a lot of re-layout, it doesn't individually animate every `<ViewTransition>` on the page. That would lead to a lot of noisy animations which distracts from the actual change. Therefore React is more conservative about when an individual animation triggers.
+
+<Pitfall>
+
+It's important to properly use keys to preserve identity when reordering lists. It might seem like you could use "name", shared element transitions, to animate reorders but that would not trigger if one side was outside the viewport. To animate a reorder you often want to show that it went to a position outside the viewport.
+
+</Pitfall>
+
+---
+
+### Animating from Suspense content {/*animating-from-suspense-content*/}
+
+Just like any Transition, React waits for data and new CSS (`<link rel="stylesheet" precedence="...">`) before running the animation. In addition to this, ViewTransitions also wait up to 500ms for new fonts to load before starting the animation to avoid them flickering in later. For the same reason, an image wrapped in ViewTransition will wait for the image to load.
+
+If it's inside a new Suspense boundary instance, then the fallback is shown first. After the Suspense boundary fully loads, it triggers the `<ViewTransition>` to animate the reveal to the content.
+
+Currently, this only happens for client-side Transition. In the future, this will also animate Suspense boundary for streaming SSR when content from the server suspends during the initial load.
+
+There are two ways to animate Suspense boundaries depending on where you place the `<ViewTransition>`:
+
+Update:
+
+```
+<ViewTransition>
+  <Suspense fallback={<A />}>
+    <B />
+  </Suspense>
+</ViewTransition>
+```
+In this scenario when the content goes from A to B, it'll be treated as an "update" and apply that class if appropriate. Both A and B will get the same view-transition-name and therefore they're acting as a cross-fade by default.
+
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div className="link">
+        <Thumbnail video={video}></Thumbnail>
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function VideoPlaceholder() {
+  const video = {image: "loading"}
+  return (
+    <div className="video">
+      <div className="link">
+        <Thumbnail video={video}></Thumbnail>
+        <div className="info">
+          <div className="video-title loading" />
+          <div className="video-description loading" />
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition,
+  Suspense
+} from 'react';
+import {Video, VideoPlaceholder} from "./Video";
+import {useLazyVideoData} from "./data"
+
+function LazyVideo() {
+  const video = useLazyVideoData();
+  return (
+    <Video video={video}/>
+  );
+}
+
+export default function Component() {
+  const [showItem, setShowItem] = useState(false);
+  return (
+    <>
+      <button
+        onClick={() => {
+          startTransition(() => {
+            setShowItem((prev) => !prev);
+          });
+        }}
+      >{showItem ? '➖' : '➕'}</button>
+      {showItem ? (
+        <ViewTransition>
+          <Suspense fallback={<VideoPlaceholder />}>
+            <LazyVideo />
+          </Suspense>
+        </ViewTransition>
+      ) : null}
+    </>
+  );
+}
+```
+
+```js src/data.js hidden
+import {use} from "react";
+
+let cache = null;
+
+function fetchVideo() {
+  if (!cache) {
+    cache = new Promise((resolve) => {
+      setTimeout(() => {
+        resolve({
+          id: '1',
+          title: 'First video',
+          description: 'Video description',
+          image: 'blue',
+        });
+      }, 1000);
+    });
+  }
+  return cache;
+}
+
+export function useLazyVideoData() {
+  return use(fetchVideo());
+}
+```
+
+
+```css
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 200px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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);
+}
+.loading {
+  background-image: linear-gradient(90deg, rgba(173, 216, 230, 0.3) 25%, rgba(135, 206, 250, 0.5) 50%, rgba(173, 216, 230, 0.3) 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+@keyframes shimmer {
+  0% {
+    background-position: -200% 0;
+  }
+  100% {
+    background-position: 200% 0;
+  }
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.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-title.loading {
+  height: 20px;
+  width: 80px;
+  border-radius: 0.5rem;
+}
+.video-description {
+  color: #5e687e;
+  font-size: 13px;
+  border-radius: 0.5rem;
+}
+.video-description.loading {
+  height: 15px;
+  width: 100px;
+}
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+Enter/Exit:
+
+```
+<Suspense fallback={<ViewTransition><A /></ViewTransition>}>
+  <ViewTransition><B /></ViewTransition>
+</Suspense>
+```
+
+In this scenario, these are two separate ViewTransition instances each with their own `view-transition-name`. This will be treated as an "exit" of the `<A>` and an "enter" of the `<B>`.
+
+You can achieve different effects depending on where you choose to place the `<ViewTransition>` boundary.
+
+---
+### Opting-out of an animation {/*opting-out-of-an-animation*/}
+
+Sometimes you're wrapping a large existing component, like a whole page, and you want to animate some updates, such as changing the theme. However, you don't want it to opt-in all updates inside the whole page to cross-fade when they're updating. Especially if you're incrementally adding more animations.
+
+You can use the class "none" to opt-out of an animation. By wrapping your children in a "none" you can disable animations for updates to them while the parent still triggers.
+
+```js
+<ViewTransition>
+  <div className={theme}>
+    <ViewTransition update="none">
+      {children}
+    </ViewTransition>
+  </div>
+</ViewTransition>
+```
+
+This will only animate if the theme changes and not if only the children update. The children can still opt-in again with their own `<ViewTransition>` but at least it's manual again.
+
+---
+
+### Customizing animations {/*customizing-animations*/}
+
+By default, `<ViewTransition>` includes the default cross-fade from the browser.
+
+To customize animations, you can provide props to the `<ViewTransition>` component to specify which animations to use, based on how the `<ViewTransition>` activates.
+
+For example, we can slow down the default cross fade animation:
+
+```js
+<ViewTransition default="slow-fade">
+  <Video />
+</ViewTransition>
+```
+
+And define slow-fade in CSS using view transition classes:
+
+```css
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+```
+
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div
+        className="link"
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition
+} from 'react';
+import {Video} from "./Video";
+import videos from "./data"
+
+function Item() {
+  return (
+    <ViewTransition default="slow-fade">
+      <Video video={videos[0]}/>
+    </ViewTransition>
+  );
+}
+
+export default function Component() {
+  const [showItem, setShowItem] = useState(false);
+  return (
+    <>
+      <button
+        onClick={() => {
+          startTransition(() => {
+            setShowItem((prev) => !prev);
+          });
+        }}
+      >{showItem ? '➖' : '➕'}</button>
+
+      {showItem ? <Item /> : null}
+    </>
+  );
+}
+```
+
+```js src/data.js hidden
+export default [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  }
+]
+```
+
+
+```css
+::view-transition-old(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+::view-transition-new(.slow-fade) {
+    animation-duration: 500ms;
+}
+
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 200px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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);
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.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;
+}
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+In addition to setting the `default`, you can also provide configurations for `enter`, `exit`, `update`, and `share` animations.
+
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div
+        className="link"
+      >
+        <Thumbnail video={video}></Thumbnail>
+
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  useState,
+  startTransition
+} from 'react';
+import {Video} from "./Video";
+import videos from "./data"
+
+function Item() {
+  return (
+    <ViewTransition enter="slide-in" exit="slide-out">
+      <Video video={videos[0]}/>
+    </ViewTransition>
+  );
+}
+
+export default function Component() {
+  const [showItem, setShowItem] = useState(false);
+  return (
+    <>
+      <button
+        onClick={() => {
+          startTransition(() => {
+            setShowItem((prev) => !prev);
+          });
+        }}
+      >{showItem ? '➖' : '➕'}</button>
+
+      {showItem ? <Item /> : null}
+    </>
+  );
+}
+```
+
+```js src/data.js hidden
+export default [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  }
+]
+```
+
+
+```css
+::view-transition-old(.slide-in) {
+  animation-name: slideOutRight;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-new(.slide-in) {
+  animation-name: slideInRight;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-old(.slide-out) {
+  animation-name: slideOutLeft;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-new(.slide-out) {
+  animation-name: slideInLeft;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+@keyframes slideOutLeft {
+  from {
+    transform: translateX(0);
+    opacity: 1;
+  }
+  to {
+    transform: translateX(-100%);
+    opacity: 0;
+  }
+}
+
+@keyframes slideInLeft {
+  from {
+    transform: translateX(-100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+@keyframes slideOutRight {
+  from {
+    transform: translateX(0);
+    opacity: 1;
+  }
+  to {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+}
+
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 200px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.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);
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.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;
+}
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+### Customizing animations with types {/*customizing-animations-with-types*/}
+You can use the [`addTransitionType`](/reference/react/addTransitionType) API to add a class name to the child elements when a specific transition type is activated for a specific activation trigger. This allows you to customize the animation for each type of transition.
+
+For example, to customize the animation for all forward and backward navigations:
+
+```js
+<ViewTransition default={{
+  'navigation-back': 'slide-right',
+  'navigation-forward': 'slide-left',
+ }}>
+  <div>...</div>
+</ViewTransition>
+ 
+// in your router:
+startTransition(() => {
+  addTransitionType('navigation-' + navigationType);
+});
+```
+
+When the ViewTransition activates a "navigation-back" animation, React will add the class name "slide-right". When the ViewTransition activates a "navigation-forward" animation, React will add the class name "slide-left".
+
+In the future, routers and other libraries may add support for standard view-transition types and styles.
+
+<Sandpack>
+
+```js src/Video.js hidden
+function Thumbnail({ video, children }) {
+  return (
+    <div
+      aria-hidden="true"
+      tabIndex={-1}
+      className={`thumbnail ${video.image}`}
+    />
+  );
+}
+
+export function Video({ video }) {
+  return (
+    <div className="video">
+      <div
+        className="link"
+      >
+        <Thumbnail video={video}></Thumbnail>
+        <div className="info">
+          <div className="video-title">{video.title}</div>
+          <div className="video-description">{video.description}</div>
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js
+import {
+  unstable_ViewTransition as ViewTransition,
+  unstable_addTransitionType as addTransitionType,
+  useState,
+  startTransition,
+} from "react";
+import {Video} from "./Video";
+import videos from "./data"
+
+function Item() {
+  return (
+    <ViewTransition enter={
+        {
+          "add-video-back": "slide-in-back",
+          "add-video-forward": "slide-in-forward"
+        }
+      }
+      exit={
+        {
+          "remove-video-back": "slide-in-forward",
+          "remove-video-forward": "slide-in-back"
+        }
+      }>
+      <Video video={videos[0]}/>
+    </ViewTransition>
+  );
+}
+
+export default function Component() {
+  const [showItem, setShowItem] = useState(false);
+  return (
+    <>
+      <div className="button-container">
+        <button
+          onClick={() => {
+            startTransition(() => {
+              if (showItem) {
+                addTransitionType("remove-video-back")
+              } else {
+                addTransitionType("add-video-back")
+              }
+              setShowItem((prev) => !prev);
+            });
+          }}
+        >⬅️</button>
+        <button
+          onClick={() => {
+            startTransition(() => {
+              if (showItem) {
+                addTransitionType("remove-video-forward")
+              } else {
+                addTransitionType("add-video-forward")
+              }
+              setShowItem((prev) => !prev);
+            });
+          }}
+        >➡️</button>
+      </div>
+      {showItem ? <Item /> : null}
+    </>
+  );
+}
+```
+
+```js src/data.js hidden
+export default [
+  {
+    id: '1',
+    title: 'First video',
+    description: 'Video description',
+    image: 'blue',
+  }
+]
+```
+
+
+```css
+::view-transition-old(.slide-in-back) {
+  animation-name: slideOutRight;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-new(.slide-in-back) {
+  animation-name: slideInRight;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-old(.slide-out-back) {
+  animation-name: slideOutLeft;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-new(.slide-out-back) {
+  animation-name: slideInLeft;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-old(.slide-in-forward) {
+  animation-name: slideOutLeft;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-new(.slide-in-forward) {
+  animation-name: slideInLeft;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-old(.slide-out-forward) {
+  animation-name: slideOutRight;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+::view-transition-new(.slide-out-forward) {
+  animation-name: slideInRight;
+  animation-duration: 500ms;
+  animation-timing-function: ease-in-out;
+}
+
+@keyframes slideOutLeft {
+  from {
+    transform: translateX(0);
+    opacity: 1;
+  }
+  to {
+    transform: translateX(-100%);
+    opacity: 0;
+  }
+}
+
+@keyframes slideInLeft {
+  from {
+    transform: translateX(-100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+@keyframes slideOutRight {
+  from {
+    transform: translateX(0);
+    opacity: 1;
+  }
+  to {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+}
+
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+#root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 200px;
+}
+button {
+  border: none;
+  border-radius: 50%;
+  width: 50px;
+  height: 50px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: #f0f8ff;
+  color: white;
+  font-size: 20px;
+  cursor: pointer;
+  transition: background-color 0.3s, border 0.3s;
+}
+button:hover {
+  border: 2px solid #ccc;
+  background-color: #e0e8ff;
+}
+.button-container {
+  display: flex;
+}
+.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);
+}
+.video {
+  display: flex;
+  flex-direction: row;
+  gap: 0.75rem;
+  align-items: center;
+  margin-top: 1em;
+}
+.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;
+}
+```
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "experimental",
+    "react-dom": "experimental",
+    "react-scripts": "latest"
+  }
+}
+```
+
+</Sandpack>
+
+### Building View Transition enabled routers {/*building-view-transition-enabled-routers*/}
+
+React waits for any pending Navigation to finish to ensure that scroll restoration happens within the animation. If the Navigation is blocked on React, your router must unblock in `useLayoutEffect` since `useEffect` would lead to a deadlock.
+
+If a `startTransition` is started from the legacy popstate event, such as during a "back"-navigation then it must finish synchronously to ensure scroll and form restoration works correctly. This is in conflict with running a View Transition animation. Therefore, React will skip animations from popstate. Therefore animations won't run for the back button. You can fix this by upgrading your router to use the Navigation API.
+
+---
+
+## Troubleshooting {/*troubleshooting*/}
+
+### My `<ViewTransition>` is not activating {/*my-viewtransition-is-not-activating*/}
+
+`<ViewTransition>` only activates if it is placed is before any DOM node:
+
+```js [3, 5]
+function Component() {
+  return (
+    <div>
+      <ViewTransition>Hi</ViewTransition>
+    </div>
+  );
+}
+```
+
+To fix, ensure that the `<ViewTransition>` comes before any other DOM nodes:
+
+```js [3, 5] 
+function Component() {
+  return (
+    <ViewTransition>
+      <div>Hi</div>
+    </ViewTransition>
+  );
+}
+```
+
+### I'm getting an error "There are two `<ViewTransition name=%s>` components with the same name mounted at the same time." {/*two-viewtransition-with-same-name*/}
+
+This error occurs when two `<ViewTransition>` components with the same `name` are mounted at the same time:
+
+
+```js [3]
+function Item() {
+  // 🚩 All items will get the same "name".
+  return <ViewTransition name="item">...</ViewTransition>;
+}
+
+function ItemList({items}) {
+  return (
+    <>
+      {item.map(item => <Item key={item.id} />)}
+    </>
+  );
+}
+```
+
+This will cause the View Transition to error. In development, React detects this issue to surface it and logs two errors:
+
+<ConsoleBlockMulti>
+<ConsoleLogLine level="error">
+
+There are two `<ViewTransition name=%s>` components with the same name mounted at the same time. This is not supported and will cause View Transitions to error. Try to use a more unique name e.g. by using a namespace prefix and adding the id of an item to the name.
+{'    '}at Item
+{'    '}at ItemList
+
+</ConsoleLogLine>
+
+<ConsoleLogLine level="error">
+
+The existing `<ViewTransition name=%s>` duplicate has this stack trace.
+{'    '}at Item
+{'    '}at ItemList
+
+</ConsoleLogLine>
+</ConsoleBlockMulti>
+
+To fix, ensure that there's only one `<ViewTransition>` with the same name mounted at a time in the entire app by ensuring the `name` is unique, or adding an `id` to the name:
+
+```js [3]
+function Item({id}) {
+  // ✅ All items will get the same "name".
+  return <ViewTransition name={`item-${id}`}>...</ViewTransition>;
+}
+
+function ItemList({items}) {
+  return (
+    <>
+      {item.map(item => <Item key={item.id} item={item} />)}
+    </>
+  );
+}
+```
diff --git a/src/content/reference/react/addTransitionType.md b/src/content/reference/react/addTransitionType.md
new file mode 100644
index 000000000..f292ea755
--- /dev/null
+++ b/src/content/reference/react/addTransitionType.md
@@ -0,0 +1,182 @@
+---
+title: unstable_addTransitionType
+version: experimental
+---
+
+<Experimental>
+
+**This API is experimental and is not available in a stable version of React yet.**
+
+You can try it by upgrading React packages to the most recent experimental version:
+
+- `react@experimental`
+- `react-dom@experimental`
+- `eslint-plugin-react-hooks@experimental`
+
+Experimental versions of React may contain bugs. Don't use them in production.
+
+</Experimental>
+
+<Intro>
+
+`unstable_addTransitionType` lets you specify the cause of a transition.
+
+
+```js
+startTransition(() => {
+  unstable_addTransitionType('my-transition-type');
+  setState(newState);
+});
+```
+
+</Intro>
+
+<InlineToc />
+
+---
+
+## Reference {/*reference*/}
+
+### `addTransitionType` {/*addtransitiontype*/}
+
+#### Parameters {/*parameters*/}
+
+- `type`: The type of transition to add. This can be any string.
+
+#### Returns {/*returns*/}
+
+`startTransition` does not return anything.
+
+#### Caveats {/*caveats*/}
+
+- If multiple transitions are combined, all Transition Types are collected. You can also add more than one type to a Transition.
+- Transition Types are reset after each commit. This means a `<Suspense>` fallback will associate the types after a `startTransition`, but revealing the content does not.
+
+---
+
+## Usage {/*usage*/}
+
+### Adding the cause of a transition {/*adding-the-cause-of-a-transition*/}
+
+Call `addTransitionType` inside of `startTransition` to indicate the cause of a transition:
+
+``` [[1, 6, "unstable_addTransitionType"], [2, 5, "startTransition", [3, 6, "'submit-click'"]]
+import { startTransition, unstable_addTransitionType } from 'react';
+
+function Submit({action) {
+  function handleClick() {
+    startTransition(() => {
+      unstable_addTransitionType('submit-click');
+      action();
+    });
+  }
+
+  return <button onClick={handleClick}>Click me</button>;
+}
+
+```
+
+When you call <CodeStep step={1}>addTransitionType</CodeStep> inside the scope of <CodeStep step={2}>startTransition</CodeStep>, React will associate <CodeStep step={3}>submit-click</CodeStep> as one of the causes for the Transition.
+
+Currently, Transition Types can be used to customize different animations based on what caused the Transition. You have three different ways to choose from for how to use them:
+
+- [Customize animations using browser view transition types](#customize-animations-using-browser-view-transition-types)
+- [Customize animations using `View Transition` Class](#customize-animations-using-view-transition-class)
+- [Customize animations using `ViewTransition` events](#customize-animations-using-viewtransition-events) 
+
+In the future, we plan to support more use cases for using the cause of a transition.
+
+---
+### Customize animations using browser view transition types {/*customize-animations-using-browser-view-transition-types*/}
+
+When a [`ViewTransition`](/reference/react/ViewTransition) activates from a transition, React adds all the Transition Types as browser [view transition types](https://www.w3.org/TR/css-view-transitions-2/#active-view-transition-pseudo-examples) to the element.
+
+This allows you to customize different animations based on CSS scopes:
+
+```js [11]
+function Component() {
+  return (
+    <ViewTransition>
+      <div>Hello</div>
+    </ViewTransition>
+  );
+}
+
+startTransition(() => {
+  unstable_addTransitionType('my-transition-type');
+  setShow(true);
+});
+```
+
+```css
+:root:active-view-transition-type(my-transition-type) {
+  &::view-transition-...(...) {
+    ...
+  }
+}
+```
+
+---
+
+### Customize animations using `View Transition` Class {/*customize-animations-using-view-transition-class*/}
+
+You can customize animations for an activated `ViewTransition` based on type by passing an object to the View Transition Class:
+
+```js
+function Component() {
+  return (
+    <ViewTransition enter={{
+      'my-transition-type': 'my-transition-class',
+    }}>
+      <div>Hello</div>
+    </ViewTransition>
+  );
+}
+
+// ...
+startTransition(() => {
+  unstable_addTransitionType('my-transition-type');
+  setState(newState);
+});
+```
+
+If multiple types match, then they're joined together. If no types match then the special "default" entry is used instead. If any type has the value "none" then that wins and the ViewTransition is disabled (not assigned a name).
+
+These can be combined with enter/exit/update/layout/share props to match based on kind of trigger and Transition Type.
+
+```js
+<ViewTransition enter={{
+  'navigation-back': 'enter-right',
+  'navigation-forward': 'enter-left',
+}}
+exit={{
+  'navigation-back': 'exit-right',
+  'navigation-forward': 'exit-left',
+}}>
+```
+
+---
+
+### Customize animations using `ViewTransition` events {/*customize-animations-using-viewtransition-events*/}
+
+You can imperatively customize animations for an activated `ViewTransition` based on type using View Transition events:
+
+```
+<ViewTransition onUpdate={(inst, types) => {
+  if (types.includes('navigation-back')) {
+    ...
+  } else if (types.includes('navigation-forward')) {
+    ...
+  } else {
+    ...
+  }
+}}>
+```
+
+This allows you to pick different imperative Animations based on the cause.
+
+---
+
+## Troubleshooting {/*troubleshooting*/}
+
+### TODO {/*todo2*/}
diff --git a/src/content/reference/react/captureOwnerStack.md b/src/content/reference/react/captureOwnerStack.md
index f8ed21a8c..6d8cc502d 100644
--- a/src/content/reference/react/captureOwnerStack.md
+++ b/src/content/reference/react/captureOwnerStack.md
@@ -2,12 +2,6 @@
 title: captureOwnerStack
 ---
 
-<Canary>
-
-The `captureOwnerStack` API is currently only available in React's Canary and experimental channels. Learn more about [React's release channels here](/community/versioning-policy#all-release-channels).
-
-</Canary>
-
 <Intro>
 
 `captureOwnerStack` reads the current Owner Stack in development and returns it as a string if available.
@@ -126,22 +120,6 @@ createRoot(document.createElement('div'), {
 );
 ```
 
-```json package.json hidden
-{
-  "dependencies": {
-    "react": "canary",
-    "react-dom": "canary",
-    "react-scripts": "latest"
-  },
-  "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
-    "test": "react-scripts test --env=jsdom",
-    "eject": "react-scripts eject"
-  }
-}
-```
-
 ```html public/index.html hidden
 <!DOCTYPE html>
 <html lang="en">
@@ -357,22 +335,6 @@ const container = document.getElementById("root");
 createRoot(container).render(<App />);
 ```
 
-```json package.json hidden
-{
-  "dependencies": {
-    "react": "canary",
-    "react-dom": "canary",
-    "react-scripts": "latest"
-  },
-  "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
-    "test": "react-scripts test --env=jsdom",
-    "eject": "react-scripts eject"
-  }
-}
-```
-
 ```js src/App.js
 function Component() {
   return <button onClick={() => console.error('Some console error')}>Trigger console.error()</button>;
@@ -417,22 +379,6 @@ export default function App() {
 }
 ```
 
-```json package.json hidden
-{
-  "dependencies": {
-    "react": "canary",
-    "react-dom": "canary",
-    "react-scripts": "latest"
-  },
-  "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
-    "test": "react-scripts test --env=jsdom",
-    "eject": "react-scripts eject"
-  }
-}
-```
-
 </Sandpack>
 
 ### `captureOwnerStack` is not available {/*captureownerstack-is-not-available*/}
diff --git a/src/content/reference/react/experimental_taintObjectReference.md b/src/content/reference/react/experimental_taintObjectReference.md
index b5b9e513d..c7b565e9b 100644
--- a/src/content/reference/react/experimental_taintObjectReference.md
+++ b/src/content/reference/react/experimental_taintObjectReference.md
@@ -1,8 +1,9 @@
 ---
 title: experimental_taintObjectReference
+version: experimental
 ---
 
-<Wip>
+<Experimental>
 
 **This API is experimental and is not available in a stable version of React yet.**
 
@@ -16,7 +17,7 @@ Experimental versions of React may contain bugs. Don't use them in production.
 
 This API is only available inside React Server Components.
 
-</Wip>
+</Experimental>
 
 
 <Intro>
diff --git a/src/content/reference/react/experimental_taintUniqueValue.md b/src/content/reference/react/experimental_taintUniqueValue.md
index de9a9beda..ea92ff6be 100644
--- a/src/content/reference/react/experimental_taintUniqueValue.md
+++ b/src/content/reference/react/experimental_taintUniqueValue.md
@@ -1,8 +1,9 @@
 ---
 title: experimental_taintUniqueValue
+version: experimental
 ---
 
-<Wip>
+<Experimental>
 
 **This API is experimental and is not available in a stable version of React yet.**
 
@@ -16,7 +17,7 @@ Experimental versions of React may contain bugs. Don't use them in production.
 
 This API is only available inside [React Server Components](/reference/rsc/use-client).
 
-</Wip>
+</Experimental>
 
 
 <Intro>
diff --git a/src/content/reference/react/experimental_useEffectEvent.md b/src/content/reference/react/experimental_useEffectEvent.md
index e819f041c..954cf6587 100644
--- a/src/content/reference/react/experimental_useEffectEvent.md
+++ b/src/content/reference/react/experimental_useEffectEvent.md
@@ -1,8 +1,9 @@
 ---
 title: experimental_useEffectEvent
+version: experimental
 ---
 
-<Wip>
+<Experimental>
 
 **This API is experimental and is not available in a stable version of React yet.**
 
@@ -14,7 +15,7 @@ You can try it by upgrading React packages to the most recent experimental versi
 
 Experimental versions of React may contain bugs. Don't use them in production.
 
-</Wip>
+</Experimental>
 
 
 <Intro>
diff --git a/src/content/reference/react/useId.md b/src/content/reference/react/useId.md
index c6be96fde..a77cf7a72 100644
--- a/src/content/reference/react/useId.md
+++ b/src/content/reference/react/useId.md
@@ -46,6 +46,8 @@ function PasswordField() {
 
 * `useId` **should not be used to generate keys** in a list. [Keys should be generated from your data.](/learn/rendering-lists#where-to-get-your-key)
 
+* `useId` currently cannot be used in [async Server Components](/reference/rsc/server-components#async-components-with-server-components).
+
 ---
 
 ## Usage {/*usage*/}
diff --git a/src/content/reference/react/useOptimistic.md b/src/content/reference/react/useOptimistic.md
index d191bbb55..b69a532f0 100644
--- a/src/content/reference/react/useOptimistic.md
+++ b/src/content/reference/react/useOptimistic.md
@@ -66,39 +66,42 @@ For example, when a user types a message into the form and hits the "Send" butto
 
 
 ```js src/App.js
-import { useOptimistic, useState, useRef } from "react";
+import { useOptimistic, useState, useRef, startTransition } from "react";
 import { deliverMessage } from "./actions.js";
 
-function Thread({ messages, sendMessage }) {
+function Thread({ messages, sendMessageAction }) {
   const formRef = useRef();
-  async function formAction(formData) {
+  function formAction(formData) {
     addOptimisticMessage(formData.get("message"));
     formRef.current.reset();
-    await sendMessage(formData);
+    startTransition(async () => {
+      await sendMessageAction(formData);
+    });
   }
   const [optimisticMessages, addOptimisticMessage] = useOptimistic(
     messages,
     (state, newMessage) => [
-      ...state,
       {
         text: newMessage,
         sending: true
-      }
+      },
+      ...state,
     ]
   );
 
   return (
     <>
+      <form action={formAction} ref={formRef}>
+        <input type="text" name="message" placeholder="Hello!" />
+        <button type="submit">Send</button>
+      </form>
       {optimisticMessages.map((message, index) => (
         <div key={index}>
           {message.text}
           {!!message.sending && <small> (Sending...)</small>}
         </div>
       ))}
-      <form action={formAction} ref={formRef}>
-        <input type="text" name="message" placeholder="Hello!" />
-        <button type="submit">Send</button>
-      </form>
+      
     </>
   );
 }
@@ -107,11 +110,13 @@ export default function App() {
   const [messages, setMessages] = useState([
     { text: "Hello there!", sending: false, key: 1 }
   ]);
-  async function sendMessage(formData) {
+  async function sendMessageAction(formData) {
     const sentMessage = await deliverMessage(formData.get("message"));
-    setMessages((messages) => [...messages, { text: sentMessage }]);
+    startTransition(() => {
+      setMessages((messages) => [{ text: sentMessage }, ...messages]);
+    })
   }
-  return <Thread messages={messages} sendMessage={sendMessage} />;
+  return <Thread messages={messages} sendMessageAction={sendMessageAction} />;
 }
 ```
 
diff --git a/src/content/reference/react/useSyncExternalStore.md b/src/content/reference/react/useSyncExternalStore.md
index 05e0c0831..ce989a2a4 100644
--- a/src/content/reference/react/useSyncExternalStore.md
+++ b/src/content/reference/react/useSyncExternalStore.md
@@ -405,14 +405,14 @@ If your store data is mutable, your `getSnapshot` function should return an immu
 
 This `subscribe` function is defined *inside* a component so it is different on every re-render:
 
-```js {4-7}
+```js {2-5}
 function ChatIndicator() {
-  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
-  
   // 🚩 Always a different function, so React will resubscribe on every re-render
   function subscribe() {
     // ...
   }
+  
+  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
 
   // ...
 }
@@ -420,28 +420,28 @@ function ChatIndicator() {
   
 React will resubscribe to your store if you pass a different `subscribe` function between re-renders. If this causes performance issues and you'd like to avoid resubscribing, move the `subscribe` function outside:
 
-```js {6-9}
-function ChatIndicator() {
-  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
+```js {1-4}
+// ✅ Always the same function, so React won't need to resubscribe
+function subscribe() {
   // ...
 }
 
-// ✅ Always the same function, so React won't need to resubscribe
-function subscribe() {
+function ChatIndicator() {
+  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
   // ...
 }
 ```
 
 Alternatively, wrap `subscribe` into [`useCallback`](/reference/react/useCallback) to only resubscribe when some argument changes:
 
-```js {4-8}
+```js {2-5}
 function ChatIndicator({ userId }) {
-  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
-  
   // ✅ Same function as long as userId doesn't change
   const subscribe = useCallback(() => {
     // ...
   }, [userId]);
+  
+  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
 
   // ...
 }
diff --git a/src/content/reference/react/useTransition.md b/src/content/reference/react/useTransition.md
index 6b7c511e7..73a754df7 100644
--- a/src/content/reference/react/useTransition.md
+++ b/src/content/reference/react/useTransition.md
@@ -77,8 +77,8 @@ function SubmitButton({ submitAction }) {
     <button
       disabled={isPending}
       onClick={() => {
-        startTransition(() => {
-          submitAction();
+        startTransition(async () => {
+          await submitAction();
         });
       }}
     >
@@ -163,7 +163,7 @@ function CheckoutForm() {
 
 The function passed to `startTransition` is called the "Action". You can update state and (optionally) perform side effects within an Action, and the work will be done in the background without blocking user interactions on the page. A Transition can include multiple Actions, and while a Transition is in progress, your UI stays responsive. For example, if the user clicks a tab but then changes their mind and clicks another tab, the second click will be immediately handled without waiting for the first update to finish. 
 
-To give the user feedback about in-progress Transitions, to `isPending` state switches to `true` at the first call to `startTransition`, and stays `true` until all Actions complete and the final state is shown to the user. Transitions ensure side effects in Actions to complete in order to [prevent unwanted loading indicators](#preventing-unwanted-loading-indicators), and you can provide immediate feedback while the Transition is in progress with `useOptimistic`.
+To give the user feedback about in-progress Transitions, the `isPending` state switches to `true` at the first call to `startTransition`, and stays `true` until all Actions complete and the final state is shown to the user. Transitions ensure side effects in Actions to complete in order to [prevent unwanted loading indicators](#preventing-unwanted-loading-indicators), and you can provide immediate feedback while the Transition is in progress with `useOptimistic`.
 
 <Recipes titleText="The difference between Actions and regular event handling">
 
@@ -227,9 +227,9 @@ import { startTransition } from "react";
 
 export default function Item({action}) {
   function handleChange(event) {
-    // To expose an action prop, call the callback in startTransition.
+    // To expose an action prop, await the callback in startTransition.
     startTransition(async () => {
-      action(event.target.value);
+      await action(event.target.value);
     })
   }
   return (
@@ -585,10 +585,9 @@ This solution makes the app feel slow, because the user must wait each time they
 
 You can expose an `action` prop from a component to allow a parent to call an Action.
 
-
 For example, this `TabButton` component wraps its `onClick` logic in an `action` prop:
 
-```js {8-10}
+```js {8-12}
 export default function TabButton({ action, children, isActive }) {
   const [isPending, startTransition] = useTransition();
   if (isActive) {
@@ -596,8 +595,10 @@ export default function TabButton({ action, children, isActive }) {
   }
   return (
     <button onClick={() => {
-      startTransition(() => {
-        action();
+      startTransition(async () => {
+        // await the action that's passed in.
+        // This allows it to be either sync or async. 
+        await action();
       });
     }}>
       {children}
@@ -656,10 +657,15 @@ export default function TabButton({ action, children, isActive }) {
   if (isActive) {
     return <b>{children}</b>
   }
+  if (isPending) {
+    return <b className="pending">{children}</b>;
+  }
   return (
-    <button onClick={() => {
-      startTransition(() => {
-        action();
+    <button onClick={async () => {
+      startTransition(async () => {
+        // await the action that's passed in.
+        // This allows it to be either sync or async. 
+        await action();
       });
     }}>
       {children}
@@ -729,10 +735,19 @@ export default function ContactTab() {
 ```css
 button { margin-right: 10px }
 b { display: inline-block; margin-right: 10px; }
+.pending { color: #777; }
 ```
 
 </Sandpack>
 
+<Note>
+
+When exposing an `action` prop from a component, you should `await` it inside the transition. 
+
+This allows the `action` callback to be either synchronous or asynchronous without requiring an additional `startTransition` to wrap the `await` in the action.
+
+</Note>
+
 ---
 
 ### Displaying a pending visual state {/*displaying-a-pending-visual-state*/}
@@ -804,8 +819,8 @@ export default function TabButton({ action, children, isActive }) {
   }
   return (
     <button onClick={() => {
-      startTransition(() => {
-        action();
+      startTransition(async () => {
+        await action();
       });
     }}>
       {children}
@@ -1095,8 +1110,8 @@ export default function TabButton({ action, children, isActive }) {
   }
   return (
     <button onClick={() => {
-      startTransition(() => {
-        action();
+      startTransition(async () => {
+        await action();
       });
     }}>
       {children}
@@ -1822,8 +1837,8 @@ import {startTransition} from 'react';
 export default function Item({action}) {
   function handleChange(e) {
     // Update the quantity in an Action.
-    startTransition(() => {
-      action(e.target.value);
+    startTransition(async () => {
+      await action(e.target.value);
     });
   }  
   return (
@@ -1933,3 +1948,162 @@ When clicking multiple times, it's possible for previous requests to finish afte
 This is expected, because Actions within a Transition do not guarantee execution order. For common use cases, React provides higher-level abstractions like [`useActionState`](/reference/react/useActionState) and [`<form>` actions](/reference/react-dom/components/form) that handle ordering for you. For advanced use cases, you'll need to implement your own queuing and abort logic to handle this.
 
 
+Example of `useActionState` handling execution order:
+
+<Sandpack>
+
+```json package.json hidden
+{
+  "dependencies": {
+    "react": "beta",
+    "react-dom": "beta"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test --env=jsdom",
+    "eject": "react-scripts eject"
+  }
+}
+```
+
+```js src/App.js
+import { useState, useActionState } from "react";
+import { updateQuantity } from "./api";
+import Item from "./Item";
+import Total from "./Total";
+
+export default function App({}) {
+  // Store the actual quantity in separate state to show the mismatch.
+  const [clientQuantity, setClientQuantity] = useState(1);
+  const [quantity, updateQuantityAction, isPending] = useActionState(
+    async (prevState, payload) => {
+      setClientQuantity(payload);
+      const savedQuantity = await updateQuantity(payload);
+      return savedQuantity; // Return the new quantity to update the state
+    },
+    1 // Initial quantity
+  );
+
+  return (
+    <div>
+      <h1>Checkout</h1>
+      <Item action={updateQuantityAction}/>
+      <hr />
+      <Total clientQuantity={clientQuantity} savedQuantity={quantity} isPending={isPending} />
+    </div>
+  );
+}
+
+```
+
+```js src/Item.js
+import {startTransition} from 'react';
+
+export default function Item({action}) {
+  function handleChange(e) {
+    // Update the quantity in an Action.
+    startTransition(() => {
+      action(e.target.value);
+    });
+  }  
+  return (
+    <div className="item">
+      <span>Eras Tour Tickets</span>
+      <label htmlFor="name">Quantity: </label>
+      <input
+        type="number"
+        onChange={handleChange}
+        defaultValue={1}
+        min={1}
+      />
+    </div>
+  )
+}
+```
+
+```js src/Total.js
+const intl = new Intl.NumberFormat("en-US", {
+  style: "currency",
+  currency: "USD"
+});
+
+export default function Total({ clientQuantity, savedQuantity, isPending }) {
+  return (
+    <div className="total">
+      <span>Total:</span>
+      <div>
+        <div>
+          {isPending
+            ? "🌀 Updating..."
+            : `${intl.format(savedQuantity * 9999)}`}
+        </div>
+        <div className="error">
+          {!isPending &&
+            clientQuantity !== savedQuantity &&
+            `Wrong total, expected: ${intl.format(clientQuantity * 9999)}`}
+        </div>
+      </div>
+    </div>
+  );
+}
+```
+
+```js src/api.js
+let firstRequest = true;
+export async function updateQuantity(newName) {
+  return new Promise((resolve, reject) => {
+    if (firstRequest === true) {
+      firstRequest = false;
+      setTimeout(() => {
+        firstRequest = true;
+        resolve(newName);
+        // Simulate every other request being slower
+      }, 1000);
+    } else {
+      setTimeout(() => {
+        resolve(newName);
+      }, 50);
+    }
+  });
+}
+```
+
+```css
+.item {
+  display: flex;
+  align-items: center;
+  justify-content: start;
+}
+
+.item label {
+  flex: 1;
+  text-align: right;
+}
+
+.item input {
+  margin-left: 4px;
+  width: 60px;
+  padding: 4px;
+}
+
+.total {
+  height: 50px;
+  line-height: 25px;
+  display: flex;
+  align-content: center;
+  justify-content: space-between;
+}
+
+.total div {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+}
+
+.error {
+  color: red;
+}
+```
+
+</Sandpack>
diff --git a/src/content/reference/rsc/directives.md b/src/content/reference/rsc/directives.md
index 42256de49..c17bcf3a8 100644
--- a/src/content/reference/rsc/directives.md
+++ b/src/content/reference/rsc/directives.md
@@ -4,7 +4,7 @@ title: Directives
 
 <RSC>
 
-Directives are for use in [React Server Components](/learn/start-a-new-react-project#bleeding-edge-react-frameworks).
+Directives are for use in [React Server Components](/reference/rsc/server-components).
 
 </RSC>
 
diff --git a/src/content/reference/rsc/server-functions.md b/src/content/reference/rsc/server-functions.md
index d29693894..770f5a705 100644
--- a/src/content/reference/rsc/server-functions.md
+++ b/src/content/reference/rsc/server-functions.md
@@ -4,7 +4,7 @@ title: Server Functions
 
 <RSC>
 
-Server Functions are for use in [React Server Components](/learn/start-a-new-react-project#bleeding-edge-react-frameworks).
+Server Functions are for use in [React Server Components](/reference/rsc/server-components).
 
 **Note:** Until September 2024, we referred to all Server Functions as "Server Actions". If a Server Function is passed to an action prop or called from inside an action then it is a Server Action, but not all Server Functions are Server Actions. The naming in this documentation has been updated to reflect that Server Functions can be used for multiple purposes.
 
@@ -28,7 +28,7 @@ To support Server Functions as a bundler or framework, we recommend pinning to a
 
 </Note>
 
-When a Server Functions is defined with the [`"use server"`](/reference/rsc/use-server) directive, your framework will automatically create a reference to the server function, and pass that reference to the Client Component. When that function is called on the client, React will send a request to the server to execute the function, and return the result.
+When a Server Function is defined with the [`"use server"`](/reference/rsc/use-server) directive, your framework will automatically create a reference to the server function, and pass that reference to the Client Component. When that function is called on the client, React will send a request to the server to execute the function, and return the result.
 
 Server Functions can be created in Server Components and passed as props to Client Components, or they can be imported and used in Client Components.
 
@@ -54,7 +54,7 @@ function EmptyNote () {
 }
 ```
 
-When React renders the `EmptyNote` Server Function, it will create a reference to the `createNoteAction` function, and pass that reference to the `Button` Client Component. When the button is clicked, React will send a request to the server to execute the `createNoteAction` function with the reference provided:
+When React renders the `EmptyNote` Server Component, it will create a reference to the `createNoteAction` function, and pass that reference to the `Button` Client Component. When the button is clicked, React will send a request to the server to execute the `createNoteAction` function with the reference provided:
 
 ```js {5}
 "use client";
diff --git a/src/content/reference/rsc/use-client.md b/src/content/reference/rsc/use-client.md
index fe6f5b1ed..e259585c4 100644
--- a/src/content/reference/rsc/use-client.md
+++ b/src/content/reference/rsc/use-client.md
@@ -5,7 +5,7 @@ titleForTitleTag: "'use client' directive"
 
 <RSC>
 
-`'use client'` is for use with [React Server Components](/learn/start-a-new-react-project#bleeding-edge-react-frameworks).
+`'use client'` is for use with [React Server Components](/reference/rsc/server-components).
 
 </RSC>
 
diff --git a/src/content/reference/rsc/use-server.md b/src/content/reference/rsc/use-server.md
index 4d6fb4639..58b1f0ee1 100644
--- a/src/content/reference/rsc/use-server.md
+++ b/src/content/reference/rsc/use-server.md
@@ -5,7 +5,7 @@ titleForTitleTag: "'use server' directive"
 
 <RSC>
 
-`'use server'` is for use with [using React Server Components](/learn/start-a-new-react-project#bleeding-edge-react-frameworks).
+`'use server'` is for use with [using React Server Components](/reference/rsc/server-components).
 
 </RSC>
 
diff --git a/src/content/versions.md b/src/content/versions.md
index 8530f6324..54bc309f1 100644
--- a/src/content/versions.md
+++ b/src/content/versions.md
@@ -11,7 +11,7 @@ The React docs at [react.dev](https://react.dev) provide documentation for the l
 We aim to keep the docs updated within major versions, and do not publish versions for each minor or patch version. When a new major is released, we archive the docs for the previous version as `x.react.dev`. See our [versioning policy](/community/versioning-policy) for more info.
 
 You can find an archive of previous major versions below.
-## Latest version: 19.0 {/*latest-version*/}
+## Latest version: 19.1 {/*latest-version*/}
 
 - [react.dev](https://react.dev) {/*docs-19*/}
 
@@ -51,6 +51,7 @@ For versions older than React 15, see [15.react.dev](https://15.react.dev).
 - [React 19 Deep Dive: Coordinating HTML](https://www.youtube.com/watch?v=IBBN-s77YSI)
 
 **Releases**
+- [v19.1.0 (March, 2025)](https://github.com/facebook/react/blob/main/CHANGELOG.md#1910-march-28-2025)
 - [v19.0.0 (December, 2024)](https://github.com/facebook/react/blob/main/CHANGELOG.md#1900-december-5-2024)
 
 ### React 18 {/*react-18*/}
diff --git a/src/content/warnings/react-dom-test-utils.md b/src/content/warnings/react-dom-test-utils.md
index 2dc33e5b2..794bb1d11 100644
--- a/src/content/warnings/react-dom-test-utils.md
+++ b/src/content/warnings/react-dom-test-utils.md
@@ -2,8 +2,6 @@
 title: react-dom/test-utils Deprecation Warnings
 ---
 
-TODO: update for 19?
-
 ## ReactDOMTestUtils.act() warning {/*reactdomtestutilsact-warning*/}
 
 `act` from `react-dom/test-utils` has been deprecated in favor of `act` from `react`.
diff --git a/src/content/warnings/react-test-renderer.md b/src/content/warnings/react-test-renderer.md
index da7623fe2..dca1761b4 100644
--- a/src/content/warnings/react-test-renderer.md
+++ b/src/content/warnings/react-test-renderer.md
@@ -2,8 +2,6 @@
 title: react-test-renderer Deprecation Warnings
 ---
 
-TODO: Update this for 19?
-
 ## ReactTestRenderer.create() warning {/*reacttestrenderercreate-warning*/}
 
 react-test-renderer is deprecated. A warning will fire whenever calling ReactTestRenderer.create() or ReactShallowRender.render(). The react-test-renderer package will remain available on NPM but will not be maintained and may break with new React features or changes to React's internals.
diff --git a/src/pages/errors/[errorCode].tsx b/src/pages/errors/[errorCode].tsx
index de9eab5bb..a67c5742d 100644
--- a/src/pages/errors/[errorCode].tsx
+++ b/src/pages/errors/[errorCode].tsx
@@ -36,7 +36,7 @@ export default function ErrorDecoderPage({
         }}
         routeTree={sidebarLearn as RouteItem}
         section="unknown">
-        <div className="whitespace-pre-line">{parsedContent}</div>
+        <div>{parsedContent}</div>
         {/* <MaxWidth>
           <P>
             We highly recommend using the development build locally when debugging
diff --git a/src/sidebarBlog.json b/src/sidebarBlog.json
index 5562a5a6c..6e92e849c 100644
--- a/src/sidebarBlog.json
+++ b/src/sidebarBlog.json
@@ -11,6 +11,27 @@
       "path": "/blog",
       "skipBreadcrumb": true,
       "routes": [
+        {
+          "title": "React Labs: View Transitions, Activity, and more",
+          "titleForHomepage": "View Transitions and Activity",
+          "icon": "blog",
+          "date": "April 23, 2025",
+          "path": "/blog/2025/04/23/react-labs-view-transitions-activity-and-more"
+        },
+        {
+          "title": "React Compiler RC",
+          "titleForHomepage": "React Compiler RC",
+          "icon": "blog",
+          "date": "April 21, 2025",
+          "path": "/blog/2025/04/21/react-compiler-rc"
+        },
+        {
+          "title": "Sunsetting Create React App",
+          "titleForHomepage": "Sunsetting Create React App",
+          "icon": "blog",
+          "date": "February 14, 2025",
+          "path": "/blog/2025/02/14/sunsetting-create-react-app"
+        },
         {
           "title": "React 19",
           "titleForHomepage": "React 19",
diff --git a/src/sidebarReference.json b/src/sidebarReference.json
index a044c9f5b..5e3939842 100644
--- a/src/sidebarReference.json
+++ b/src/sidebarReference.json
@@ -103,6 +103,16 @@
         {
           "title": "<Suspense>",
           "path": "/reference/react/Suspense"
+        },
+        {
+          "title": "<Activity>",
+          "path": "/reference/react/Activity",
+          "version": "experimental"
+        },
+        {
+          "title": "<ViewTransition>",
+          "path": "/reference/react/ViewTransition",
+          "version": "experimental"
         }
       ]
     },
@@ -117,6 +127,9 @@
         {
           "title": "cache",
           "path": "/reference/react/cache"
+        },        {
+          "title": "captureOwnerStack",
+          "path": "/reference/react/captureOwnerStack"
         },
         {
           "title": "createContext",
@@ -141,17 +154,17 @@
         {
           "title": "experimental_taintObjectReference",
           "path": "/reference/react/experimental_taintObjectReference",
-          "version": "canary"
+          "version": "experimental"
         },
         {
           "title": "experimental_taintUniqueValue",
           "path": "/reference/react/experimental_taintUniqueValue",
-          "version": "canary"
+          "version": "experimental"
         },
         {
-          "title": "captureOwnerStack",
-          "path": "/reference/react/captureOwnerStack",
-          "version": "canary"
+          "title": "unstable_addTransitionType",
+          "path": "/reference/react/addTransitionType",
+          "version": "experimental"
         }
       ]
     },
diff --git a/src/siteConfig.js b/src/siteConfig.js
index f1da16720..c15b3462b 100644
--- a/src/siteConfig.js
+++ b/src/siteConfig.js
@@ -2,7 +2,7 @@
  * Copyright (c) Facebook, Inc. and its affiliates.
  */
 exports.siteConfig = {
-  version: '19',
+  version: '19.1',
   // --------------------------------------
   // Translations should replace these lines:
   languageCode: 'az',
diff --git a/src/styles/index.css b/src/styles/index.css
index 281111092..6b2915be4 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -12,7 +12,17 @@
     font-style: normal;
     font-weight: 400;
     font-display: swap;
-    src: url('/fonts/Source-Code-Pro-Regular.woff2') format('woff2');
+    src: url('https://react.dev/fonts/Source-Code-Pro-Regular.woff2')
+      format('woff2');
+  }
+
+  @font-face {
+    font-family: 'Source Code Pro';
+    font-style: normal;
+    font-weight: 700;
+    font-display: swap;
+    src: url('https://react.dev/fonts/Source-Code-Pro-Bold.woff2')
+      format('woff2');
   }
 
   /* Latin */