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.
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>
);
}
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 { useId, 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} ({`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();
}
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 { useId, useState } 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(":", "");
}
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");
});
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.