Optimization Techniques for Rive Animations in React Apps

What is Rive?
Rive is a modern tool for creating high-performance, interactive animations that you can run anywhere. It's easy to use, works beautifully with vector graphics, has a solid developer toolkit, and comes with plenty of powerful features, like state machines and mesh deformation. We often use it to build stunning marketing sites.
You'll find a ton of fascinating examples in Rive's community library.
What can we optimize?
Rive claims all of its animations already perform great, and it's true: compared to Lottie, the performance difference is noticeable right away.
So I'm not going to explain how to draw and animate in Rive. I'm going to tell you how to optimize the loading of Rive animations for a better user experience.
Room for improvement
Using Rive in a React project is very simple. Following the official guide, you can do it like this.
import Rive from '@rive-app/react-canvas';export const Simple = () => <Rive src="https://cdn.rive.app/animations/vehicles.riv" />;
When the component mounts, Rive loads a separate WASM runtime library during initialization. Rive uses this library to control and interact with animations. I assume that's also the reason its performance is so much better than alternatives like Lottie. This WASM module isn't small: it weighs 78KB.
The problem is that, in this case, the WASM module and the .riv animation file don't start loading until React runs, so if you have a Rive animation in your page's Hero section, it might not appear until 4-5 seconds have passed if the page is fairly heavy.
Even if you use modern frameworks with SSG/SSR and the initial HTML reaches you fairly quickly, you'll still have to wait for React hydration before the Rive animation initializes.
After measuring the initial page load time, component mount time, and animation start time, I got these numbers on a sample project:
React component mounted: ~2.5sRive animation playing: ~3.5s
These are average values and can vary depending on your location and connection. Even so, it's clear there's room for improvement. My goal was to minimize the gap between the component mounting and the animation starting, and here's what you can do.
Self-host the WASM
If you look closely at the network tab, you'll see Rive doesn't include the WASM module in your project's bundle and always loads it from
https://unpkg.com/@rive-app/canvas@1.0.64/rive.wasm
That opens an extra connection I'd rather avoid, in order to serve all files from the same CDN. To fix it, we can use this API:
import riveWasmUrl from '@rive-app/canvas/rive.wasm';import { RuntimeLoader } from 'rive-react';RuntimeLoader.setWasmUrl(riveWasmUrl);
You can add this code to the page component file where you use Rive. It won't work unless you also add a URL loader for wasm files to your webpack configuration.
So, if you use Gatsby, do this:
exports.onCreateWebpackConfig = ({ loaders, actions }) => { actions.setWebpackConfig({ module: { rules: [ { test: /\.wasm$/, use: [loaders.url()], }, ], }, });};
And this, if you use Next.js:
module.exports = { webpack: (config, options) => { config.module.rules.push({ test: /\.wasm$/, use: ['url-loader'], }); return config; },};
It's better now, but this wasn't the biggest problem.
Preload the WASM
Next, we can preload the WASM module with link rel="preload"
Since we've already adapted webpack to load the WASM, preloading it is very simple. Add it to your main layout if you use Rive everywhere, or include it with React Helmet on the pages where you need it.
import riveWasmUrl from '@rive-app/canvas/rive.wasm';<link rel="preload" href={riveWasmUrl} as="fetch" crossOrigin="anonymous" />;
Preload the .riv animation
You can do the same with the .riv file on the page where it plays.
<link rel="preload" href="/path-to-rive-file/hero.riv" as="fetch" crossOrigin="anonymous" />
Results
With this solution I got the gap between the React component mounting and the Rive animation starting down to practically nothing. The remaining difference was around 30ms.
I also have a few more tricks that can help in certain situations.
Lazy loading Rive
If you use small animations throughout the page, it's easy to load them all upfront. But if you have complex Rive graphics and animations, they can easily exceed 20KB. That's why I recommend using the Intersection Observer API to load them only when they're visible.
In the example below you can see how the .riv file only loads as we approach the section with the Rive animation
To pull this off, you need to understand how the Rive component for React works. According to Rive's source code, it loads the .riv file when the component mounts, which means that, to avoid loading extra files, we need to prevent <RiveComponent> from rendering.
Then, with react-intersection-observer, we can subscribe to the visibility of the section with the animation.
And finally, we wrap RiveComponent with <ImagePlaceholder> to avoid layout shifts on the page.
ImagePlaceholder is a simple solution that uses an svg placeholder to maintain the image's required aspect ratio. As an alternative, you can use the modern CSS
aspect-ratioproperty or the classicpadding-bottomtrick. You'll find the source code in the demo.
Below is a sample app showing how it works (see demo)
import './styles.css';import { useRive } from '@rive-app/react-canvas';import { useInView } from 'react-intersection-observer';import riveBotAnimationUrl from './bot.riv';import ImagePlaceholder from './ImagePlaceholder';const ART_BOARD = 'HeroBot';const STATE_MACHINE_NAME = 'State Machine 1';export default function App() { const { RiveComponent, rive } = useRive({ src: riveBotAnimationUrl, autoplay: true, stateMachines: STATE_MACHINE_NAME, artboard: ART_BOARD, }); const [ref, isInView] = useInView({ triggerOnce: true, // rootMargin: "200px" threshold: 0.2, }); return ( <div className="App"> <section className="hero"> <div> <h1>Rive Lazy Loading Example</h1> <p>Scroll down the page and look at network tab</p> </div> </section> <section ref={ref} className="animation-container"> <ImagePlaceholder height={400} width={400}> {isInView && <RiveComponent />} </ImagePlaceholder> </section> </div> );}
Note: Rive's WASM module also loads automatically the first time a RiveComponent is initialized, so by deferring the rendering you're also deferring the loading of a fairly heavy runtime.
Rive, React.lazy and Suspense
The last optimization, very useful if you don't want to show Rive animations on certain devices, involves using React 18's Suspense and lazy component loading. The architecture of this solution is:
- Move the component that loads, controls and plays the Rive animation into its own separate React component.
- Import it with React.lazy. This will generate a separate JS bundle.
- Wrap the lazily loaded component in Suspense.
const RiveAnimation = lazy(() => import('components/rive-animation'))const Section = () => {return ( {isMobile ? ( <img src="my-example.png"/> ) : ( <Suspense fallback={<Loading />}> <RiveAnimation /> </Suspense> )} )}export default Section
With this approach you can put together a full demo of the solution in very little time.
And that's it!
Let's recap how to get the most out of Rive with React:
- Self-host the WASM runtime library. This avoids an extra http connection to unpkg's external server, where it's hosted by default.
- Preload the .riv and WASM files if you have a Rive animation in the page's Hero section. It will drastically cut load and execution times.
- Create a separate bundle for Rive with React lazy and Suspense so you don't load the library on slow networks or mobile devices.