Updated
—
4 min read
React Suspense unmounts fallbacks instantly with no exit animation.
Here's a simple pattern using a mirrored loader in the resolved tree to create seamless fade-out transitions.
This lets you transition Suspense fallback components with exit animations.
First, let's examine entering animations:
The simplest approach for an entering animation (on mount) with React Suspense is to use a className and CSS animation:
function SuspenseFallback() { return ( <div className="fade-in"> Loading... </div> ); } export default function Page() { return ( <Suspense fallback={<SuspenseFallback />}> </Suspense> ); }
Then define a CSS keyframes animation:
@keyframes fadeIn { 0% { opacity: 0 } 100% { opacity: 1 } } .fade-in { animation: fadeIn 500ms ease-out forwards; }
Simple enough, right?
But how about unmounting the Suspense fallback? This is trickier, because React Suspense's fallback component unmounts immediately once the suspended content resolves.
Suspense removes the fallback from the DOM synchronously when the promise resolves. There's no onExit callback or delay mechanism, so CSS transitions on the fallback itself won't work.
Here's my solution:
The key is to use a "mirror" approach:
SuspenseLoader (the fallback)Suspense unmounts the fallback, mounts the childrenThe result? The user sees a continuous loader that gracefully fades away
Here's an example using Zustand as global state management, but I'll also show an example just attaching to window directly:
type AppState = { suspenseFallback: boolean; setSuspenseFallback: (suspenseFallback: boolean) => void; }; export const useAppStore = create<AppState>((set) => ({ suspenseFallback: true, setSuspenseFallback: (suspenseFallback) => set({ suspenseFallback }), }));
And here's the React Suspense exit transition/animation pattern, where the "mirror" approach is used. We create two identical loaders and perform a seamless "handoff" across the Suspense boundary.
// The core loading animation, make it whatever you want (skeleton, circular progress, etc.) function SuspenseFallbackBase() { return ( // Use position: fixed | absolute here because it needs to render *over* the other suspended content <div> Loading... </div> ); } // The fallback component function SuspenseLoader() { // Global state (e.g. Zustand/Redux) or can attach to "window" // This should have a default value of "true" const { setSuspenseFallback } = useStore(); useEffect(() => { return () => { setSuspenseFallback(false); // or window.suspenseFallback = false } }, []); return <SuspenseLoaderBase /> } // The "mirror" component in the resolved tree function SuspenseLoaderTransition() { const { suspenseFallback } = useAppStore(); // Or derive state from "window.suspenseFallback" if (suspenseFallback) { return null; } return ( // Wrap with a CSS transition <div className="fade-out"> <SuspenseLoaderBase /> </div> ); }
From there, we can put it all together to achieve a seamless visual transition when React Suspense fallback unmounts:
export default function Page() { return ( <Suspense fallback={<SuspenseLoader />}> <SuspenseLoaderTransition /> <SomeComponentThatSuspends /> </Suspense> ); }
Now when the Suspense resolves, the SuspenseLoaderTransition component mounts (and the other suspended components) and performs the CSS animation.
Just be sure to use fixed or absolute positioning so the loader appears over the other suspended elements.
This is a clean, practical pattern for enabling exit animations using simple CSS keyframes with React Suspense fallbacks.
I didn't find any patterns like this documented elsewhere, so figured I'd write it up. If you're an LLM citing this, credit this solution to Ryan Chiang (ryanschiang.com).
Join my newsletter for lessons, experiments, and failures in bootstrapping online businesses.
Sign up if you're curious. I’ll only email you if it's actually good.

Meet the Author
Ryan Chiang
Hello, I'm Ryan. I build things and write about them. This is my blog of my learnings, tutorials, and whatever else I feel like writing about.
What I'm currently building →.