A React trick to improve exit animations
Suspense can pause a component's DOM updates, ensuring smoother exit animations.
The problem
When I was building out the component library for the Clerk dashboard I often ran into an issue with exit animations: the component’s contents would change during the animation.
I think this is distracting. You probably don’t care about something that’s exiting, but if it updates at the same time it draws attention to itself.
The problem is that in React, updates that trigger exit animations often affect the contents of the exiting component too. An example can be seen in the left demo above, where the search placeholder changes once you select a label and selected labels are kept at the top of the list for quick access. React batches these updates with the one that closes the popover.
Exploration
I tried a few workarounds to prevent these unwanted updates. The first was to save the component’s state before exiting and use that state during the animation to prevent re-renders. This worked but I felt it was too burdensome for other engineers building their own components, as you had to keep track of all the state that could cause the exiting component to re-render.
I’ve also seen approaches that delay updating certain state until exit animations complete.
Libraries like Base UI include props like onOpenChangeComplete to
make this easier. This can work, but I think it can get a bit complex
when handling interruptions to the exit animations.
What I really wanted was a way to tell React to stop committing DOM changes in exiting components. Something like this:
<Select.Root>
<Select.Trigger />
<Freeze frozen={isExiting}>
<Select.Content />
</Freeze>
</Select.Root>
(Hat tip to my coworker Rafa who proposed the name Freeze.)
The solution
After a bit of digging, I discovered one component in React that behaves this exact way: Suspense. React continues to render suspended subtrees but doesn’t commit their changes to the DOM, so they remain “frozen” in their pre-suspended state.
The only problem is that React also hides them with display: none !important while they’re suspended.
My final solution was to create a small wrapper around Suspense that un-applied this style.
I used a Fragment ref to target the child DOM nodes of the Suspense.
Here’s the full code:
import * as React from 'react'
export function Freeze({ frozen, children }: { frozen: boolean; children: React.ReactNode }) {
const elementsRef = React.useRef(new Set<HTMLElement>())
const fragmentRef: React.RefCallback<React.FragmentInstance> = (frag) => {
if (!frag) return
// Use a custom observer to store the child DOM nodes in a ref,
// because FragmentInstance doesn't offer an API to do it directly.
const observer = new ElementsObserver(elementsRef)
frag.observeUsing(observer)
return () => {
frag?.unobserveUsing(observer)
}
}
// An insertion effect is the earliest opportunity to undo Suspense's `display: none`
React.useInsertionEffect(() => {
if (!frozen) return
elementsRef.current.forEach((element) => {
element.style.display = ''
})
}, [frozen])
return (
<React.Fragment ref={fragmentRef}>
<React.Suspense>
{frozen && <Suspend />}
{children}
</React.Suspense>
</React.Fragment>
)
}
// A custom observer to store DOM nodes in a ref:
class ElementsObserver {
constructor(private readonly elementsRef: React.RefObject<Set<HTMLElement>>) {}
observe(element: HTMLElement) {
this.elementsRef.current.add(element)
}
unobserve(element: HTMLElement) {
this.elementsRef.current.delete(element)
}
disconnect() {
this.elementsRef.current.clear()
}
}
const infinitePromise = new Promise<never>(() => {})
function Suspend() {
React.use(infinitePromise)
return null
}
(Note: this simple implementation will wipe out any inline display style the element had before it was frozen.)
And here’s an example using React Aria Components:
<Select>
<Button>
<SelectValue />
</Button>
<Popover>
{({ isExiting }) => (
<Freeze frozen={isExiting}>
<ListBox>{/* ... */}</ListBox>
</Freeze>
)}
</Popover>
</Select>
You can see this in action in the right demo above.
Closing thoughts
I’ve used this in a few projects now and it’s worked well so far.
It works particularly well with React Aria Components which uses
React state for hover, press, and focus interactions, so
things like hover state get “frozen” too (compared to CSS :hover which doesn’t freeze).
It’s obviously a bit of a trick, though, and something that could break in a future version of React. I tested it in React 18 & 19.2 and it worked in both of them, though.
I’m hopeful the React team could add support for this behavior, especially after reading their blog post about Activity:
while researching [Activity] we realized that it’s possible for parts of the app to be visible and inactive, such as content behind a modal.
”Visible and inactive” sounds similar to the “frozen” state here, so I hope they consider supporting it with an Activity mode in the future.