React: View Transitions for Components

View transitions is a useful feature, especially for react. With it, we do not need any libraries to animate the mounting and more so unmounting of DOM elements. Previously we had to use libraries like react-transition-group, react-spring or framer-motion to achieve this. In this article we will take a look at how we can use view transitions in react by creating an accordion component.

Accordion

Our initial setup is a simple accordion component. It has a list of items, and when you click on an item, it will expand to show more information.

import { useState } from "react";

function Accordion({ items }) {
  const [selectedItem, setSelectedItem] = useState(undefined);

  return (
    <div>
      {items.map((tab, index) => (
        <div key={index}>
          <button
            onClick={() => {
              setSelectedItem(selectedItem === index ? undefined : index);
            }}
          >
            {tab.name}
          </button>
          {selectedItem === index && tab.content}
        </div>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <Accordion
      items={[
        {
          name: "Tab 1",
          content: <div>Tab 1 content</div>,
        },
        {
          name: "Tab 2",
          content: <div>Tab 2 content</div>,
        },
      ]}
    ></Accordion>
  );
}

To animate the accordion, we need to do a few things:

  • Add a view transition name to each accordion button (we will only animate the button, not the content)
  • Add a document.startViewTransition call to the onClick handler.
  • Add a flushSync call to the onClick handler to immediately update the DOM to reflect the new state.
import { useState } from "react";
import { flushSync } from "react-dom";

function Accordion({ items }) {
  const [selectedItem, setSelectedItem] = useState(undefined);

  return (
    <div>
      {items.map((tab, index) => (
        <div key={index}>
          <button
            style={{
              viewTransitionName: `heading-${index}`,
            }}
            onClick={() => {
              document.startViewTransition(() => {
                flushSync(() => {
                  setSelectedItem(selectedItem === index ? undefined : index);
                });
              });
            }}
          >
            {tab.name}
          </button>
          {selectedItem === index && tab.content}
        </div>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <Accordion
      items={[
        {
          name: "Tab 1",
          content: <div>Tab 1 content</div>,
        },
        {
          name: "Tab 2",
          content: <div>Tab 2 content</div>,
        },
      ]}
    ></Accordion>
  );
}

Multiple Components

You might have noticed that our accordion component is not very reusable: it only works while one accordion is on the page. When we add another instance of the accordion component, all view transitions break, as can be seen below.

import { useState, useId } from "react";
import { flushSync } from "react-dom";

function Accordion({ items }) {
  const [selectedItem, setSelectedItem] = useState(undefined);

  return (
    <div>
      {items.map((tab, index) => (
        <div key={index}>
          <button
            style={{
              viewTransitionName: `heading-${index}`,
            }}
            onClick={() => {
              document.startViewTransition(() => {
                flushSync(() => {
                  setSelectedItem(selectedItem === index ? undefined : index);
                });
              });
            }}
          >
            {tab.name} ({`viewTransitionName: heading-${index}`})
          </button>
          {selectedItem === index && tab.content}
        </div>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <>
      <Accordion
        items={[
          {
            name: "Tab 1",
            content: <div>Tab 1 content</div>,
          },
          {
            name: "Tab 2",
            content: <div>Tab 2 content</div>,
          },
        ]}
      ></Accordion>
      <br />
      <Accordion
        items={[
          {
            name: "Tab 1",
            content: <div>Tab 1 content</div>,
          },
          {
            name: "Tab 2",
            content: <div>Tab 2 content</div>,
          },
        ]}
      ></Accordion>
    </>
  );
}

This is because we are using the same view transition names for each accordion. If not all view transition names on the page are unique, the browser will not be able to tell what the source and the destination are for each of the elements that should be transitioned.

So, let's construct a unique name for each element. Conveniently, React has a hook we can use for unique ids: useId (link to docs: https://react.dev/reference/react/useId).

import { useId } from "react";

function Component() {
  const id = useId(); // :r0:
}

There is one problem: we cannot readily use it with view transition names. A view transition name is a CSS value, and css values cannot contain : colon characters (see https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident). Thus, we must escape the colon character.

We might be tempted to use the CSS.escape method, but for some reason that will not work as expected. So let's instead write our own function to do the escaping with a simple replaceAll.

import { useId } from "react";

function useCssId() {
  return useId().replaceAll(":", "");
}
import { useState, useId } from "react";
import { flushSync } from "react-dom";

function Accordion({ items }) {
  const [selectedItem, setSelectedItem] = useState(undefined);

  const id = useCssId();

  return (
    <div>
      {items.map((tab, index) => (
        <div key={index}>
          <button
            style={{
              viewTransitionName: `heading-${id}-${index}`,
            }}
            onClick={() => {
              document.startViewTransition(() => {
                flushSync(() => {
                  setSelectedItem(selectedItem === index ? undefined : index);
                });
              });
            }}
          >
            {tab.name} ({`viewTransitionName: heading-${id}-${index}`})
          </button>
          {selectedItem === index && tab.content}
        </div>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <>
      <Accordion
        items={[
          {
            name: "Tab 1",
            content: <div>Tab 1 content</div>,
          },
          {
            name: "Tab 2",
            content: <div>Tab 2 content</div>,
          },
        ]}
      ></Accordion>
      <br />
      <Accordion
        items={[
          {
            name: "Tab 1",
            content: <div>Tab 1 content</div>,
          },
          {
            name: "Tab 2",
            content: <div>Tab 2 content</div>,
          },
        ]}
      ></Accordion>
    </>
  );
}

function useCssId() {
  return useId().replaceAll(":", "");
}

Browser Support

While view transitions are not yet supported by all browsers, it is a good idea to check for support before calling startViewTransition. We can do this by checking if the startViewTransition method exists on the document object.

if (document.startViewTransition) {
  document.startViewTransition(...);
}

We can create a helper function to make this easier to use.

function startViewTransition(callback) {
  if (document.startViewTransition) {
    document.startViewTransition(callback);
  } else {
    callback();
  }
}

Actually, we may as well include flushSync there, since we will only use it in the context of React.

function startViewTransition(callback) {
  if (document.startViewTransition) {
    document.startViewTransition(() => {
      flushSync(callback);
    });
  } else {
    callback();
  }
}

Then, we can use it like this:

startViewTransition(() => {
  setState("new state");
});

Is flushSync necessary?

We can remove the flushSync call from the accordion component, and everything will still work as expected.

import { useState } from "react";

export default function App() {
  const [selectedItem, setSelectedItem] = useState(undefined);

  const items = [
    {
      name: "Tab 1",
      key: "tab1",
      content: <div>Tab 1 content</div>,
    },
    {
      name: "Tab 2",
      key: "tab2",
      content: <div>Tab 2 content</div>,
    },
  ];

  return (
    <div>
      {items.map((tab, index) => (
        <div key={tab.key}>
          <button
            style={{
              viewTransitionName: `heading-${tab.key}`,
            }}
            onClick={() => {
              document.startViewTransition(() => {
                setSelectedItem(selectedItem === index ? undefined : index);
              });
            }}
          >
            {tab.name}
          </button>
          {selectedItem === index && tab.content}
        </div>
      ))}
    </div>
  );
}

There may be situations where flushSync is necessary, I will need to update this article if I find any good examples.

Using Tailwind together with NextraTypography in UI Libraries