April 23, 2025 by Ricky Hanlon


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.

Today, we’re excited to release documentation for two new experimental features that are ready for testing:

We’re also sharing updates on new features currently in development:


New Experimental Features

Note

<Activity /> has shipped in [email protected].

<ViewTransition /> and addTransitionType are now available in react@canary.

View Transitions and Activity are now ready for testing in react@experimental. These features have been tested in production and are stable, but the final API may still change as we incorporate feedback.

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>: A component that lets you activate an animation for a Transition.
  • addTransitionType: A function that allows you to specify the cause of a Transition.
  • <Activity>: A component that lets you hide and show parts of the UI.

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 API available in most modern browsers.

To opt-in to animating an element, wrap it in the new <ViewTransition> component:

// "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:

// "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 applied (typically a smooth cross-fade). You can use view transition pseudo-selectors 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—like startTransition, useDeferredValue, or a Suspense fallback switching to content—React will use declarative heuristics 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 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.

Note

View Transitions do not replace CSS and JS driven animations

View Transitions are meant to be used for UI transitions such as navigation, expanding, opening, or re-ordering. They are not meant to replace all the animations in your app.

In our example app above, notice that there are already animations when you click the “like” button and in the Suspense fallback glimmer. These are good use cases for CSS animations because they are animating a specific element.

Animating navigations

Our app includes a Suspense-enabled router, with page transitions already marked as Transitions, which means navigations are performed with startTransition:

function navigate(url) {

startTransition(() => {

go(url);

});

}

startTransition is a View Transition trigger, so we can add <ViewTransition> to animate between pages:

// "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:

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?

Note

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:

// Layout.js

<ViewTransition default="none">

{children}

</ViewTransition>

In practice, navigations should be done via “enter” and “exit” props, or by using Transition Types.

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:

<ViewTransition default="slow-fade">

<Home />

</ViewTransition>

And define slow-fade in CSS using view transition classes:

::view-transition-old(.slow-fade) {

animation-duration: 500ms;

}

::view-transition-new(.slow-fade) {

animation-duration: 500ms;

}

Now, the cross fade is slower:

import { ViewTransition } from "react";
import Details from "./Details";
import Home from "./Home";
import { useRouter } from "./router";

export default function App() {
  const { url } = useRouter();

  
  
  return (
    <ViewTransition default="slow-fade">
      {url === '/' ? <Home /> : <Details />}
    </ViewTransition>
  );
}

See Styling View Transitions for a full guide on styling <ViewTransition>.

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>:

<ViewTransition name={`video-${video.id}`}>

<Thumbnail video={video} />

</ViewTransition>

Now the video thumbnail animates between the two pages:

import { useState, ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react";

export function Thumbnail({ video, children }) {
  
  
  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>
  );
}

By default, React automatically generates a unique name for each element activated for a transition (see 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.

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:

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”:

<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:

::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:

import {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">
          {}
          <ViewTransition
            name="nav"
            share={{
              'nav-forward': 'slide-forward',
              'nav-back': 'slide-back',
            }}>
            {heading}
          </ViewTransition>
          {isPending && <span className="loader"></span>}
        </div>
      </div>
      {}
      {}
      <ViewTransition default="none">
        <div className="bottom">
          <div className="content">{children}</div>
        </div>
      </ViewTransition>
    </div>
  );
}

Animating Suspense Boundaries

Suspense will also activate View Transitions.

To animate the fallback to content, we can wrap Suspense with <ViewTranstion>:

<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:

import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons";

function VideoDetails({ id }) {
  
  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>
  );
}

We can also provide custom animations using an exit on the fallback, and enter on the content:

<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:

::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:

import { use, Suspense, ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons";

function VideoDetails({ id }) {
  return (
    <Suspense
      fallback={
        
        <ViewTransition exit="slide-down">
          <VideoInfoFallback />
        </ViewTransition>
      }
    >
      {}
      <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>
    </>
  );
}

Animating Lists

You can also use <ViewTransition> to animate lists of items as they re-order, like in a searchable list of items:

<div className="videos">

{filteredVideos.map((video) => (

<ViewTransition key={video.id}>

<Video video={video} />

</ViewTransition>

))}

</div>

To activate the ViewTransition, we can use useDeferredValue:

const [searchText, setSearchText] = useState('');

const deferredSearchText = useDeferredValue(searchText);

const filteredVideos = filterVideos(videos, deferredSearchText);

Now the items animate as you type in the search bar:

import { useId, useState, use, useDeferredValue, ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons";

function SearchList({searchText, videos}) {
  
  const deferredSearchText = useDeferredValue(searchText);
  const filteredVideos = filterVideos(videos, deferredSearchText);
  return (
    <div className="video-list">
      <div className="videos">
        {filteredVideos.map((video) => (
          
          <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)));
  });
}

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:

If you’re curious to know more about how they work, check out How Does <ViewTransition> Work in the docs.

For more background on how we built View Transitions, see: #31975, #32105, #32041, #32734, #32797 #31999, #32031, #32050, #32820, #32029, #32028, and #32038 by @sebmarkbage (thanks Seb!).


Activity

Note

In past updates, 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:

<Activity mode={isVisible ? 'visible' : 'hidden'}>

<Page />

</Activity>

When an Activity is visible it’s rendered as normal. When an Activity is hidden it is unmounted, but will save its state and continue to render at a lower priority than anything visible on screen.

You can use Activity to save state for parts of the UI the user isn’t using, or pre-render parts that a user is likely to use next.

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 guide. To eagerly find problematic Effects, we recommend adding <StrictMode> which will eagerly perform Activity unmounts and mounts to catch any unexpected side effects.

Restoring state with Activity

When a user navigates away from a page, it’s common to stop rendering the old page:

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:

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”:

import { Activity, ViewTransition } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router";

export default function App() {
  const { url } = useRouter();

  return (
    
    <ViewTransition>
      {}
      <Activity mode={url === '/' ? 'visible' : 'hidden'}>
        <
            
            
                            
            
                            
#Frontend
React Blog

Publisher

Originally by Ricky Hanlon


0 Comments

Log in to join the conversation.

No comments yet. Be the first to share your thoughts.