---
title: "Adding Rive and Lottie Animations to Android Paywalls"
description: "This article covers how Rive and Lottie bundles work, what changes in your Android code, and the key constraints to know before choosing one."
language: "en"
publishedAt: "2026-09-07T00:35:05.530Z"
updatedAt: "2026-09-07T00:35:05.530Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
canonical: "https://www.revenuecat.com/blog/engineering/paywalls-custom-anims"
---

# Adding Rive and Lottie Animations to Android Paywalls

This article covers how Rive and Lottie bundles work, what changes in your Android code, and the key constraints to know before choosing one.

## Table of contents

- [Where the component vocabulary stops](#where-the-component-vocabulary-stops)
- [What a custom component is](#what-a-custom-component-is)
- [Rive: A state machine driven character](#rive-a-state-machine-driven-character)
  - [Fit and alignment: Matching the artboard to the box](#fit-and-alignment-matching-the-artboard-to-the-box)
- [Lottie: A vector animation from a JSON document](#lottie-a-vector-animation-from-a-json-document)
- [Uploading in the dashboard](#uploading-in-the-dashboard)
- [What changes in your Android code](#what-changes-in-your-android-code)
- [Availability and constraints](#availability-and-constraints)
- [Conclusion](#conclusion)

Paywalls V2 gives you a component tree to build with: stacks, text, buttons, images, video, carousels, timelines. It covers the layouts most paywalls need, and the whole tree is defined on the server, so you change it from the dashboard without shipping an app release. But the vocabulary is fixed. When you want a Rive character reacting through a state machine, or a Lottie celebration that fires the moment a trial starts, no component expresses it. Custom components close that gap by reserving a box on the paywall and letting you decide what goes inside it.

In this article, you'll explore where the component vocabulary runs out, how the Rive and Lottie bundles are structured and the two decisions that make them work, what the upload step involves, what changes in your Android code, and the constraints worth knowing before you commit to one.

## **Where the component vocabulary stops**

Every component in a Paywalls V2 tree is something the SDK knows how to interpret. A `stack` arranges children, a `text` renders a localized string, an `image` draws a remote asset. Your paywall config is a composition of these known types, which is exactly why it can be served as JSON and rendered by a binary that has never seen that particular paywall.

The consequence is that you can compose the vocabulary, but you cannot extend it. An animation format is not expressible as a stack of text and images. Before custom components, you had three options:

- **Export the animation as a video.** The `video` component gives you motion with no bundle to build, and for a fixed clip it is still the right answer. It cannot react to state, and a video costs far more bytes than the vector data that produces the same motion.
- **Ship a native Composable and gate it.** You write the animation in your app, then guard it behind a flag or an offering identifier. This works, but it needs an app release for every change, which gives up the reason you moved to server driven paywalls.
- **Fall back to a static image.** Simple and immediate, and it throws away the motion entirely.
A custom component targets the case none of those cover: content that is interactive, vector based, and changeable from the dashboard. The RevenueCat docs name the use case directly, describing custom components as usable "for interactive elements such as Lottie or Rive animations and animated backgrounds."

## **What a custom component is**

A custom component is a folder of web files with `index.html` at its root. You zip that folder and upload it to the component in the dashboard. RevenueCat validates the archive, unpacks it, and serves the files over HTTPS from a subdomain dedicated to that upload. In the tree, you place it and size it the way you place any other component.

Three properties shape how you build one:

- **It is self contained.** Everything the bundle needs travels inside the zip: scripts, styles, fonts, animation data, and the animation runtime itself. Once the bundle has loaded there is no further third party fetch, so the animation cannot fail because some CDN was slow.
- **It runs under a strict Content Security Policy.** This is the rule that shapes all the code below. The bundle cannot call `fetch()`, cannot use `eval()` or `new Function`, and cannot use inline `<script>` blocks. These are hard failures, not degradations.
- **It is decorative.** The docs are explicit that "paywall elements such as packages and purchase buttons must remain native." Selection and purchase stay on real components, where the SDK owns the purchase path.
Inside the box you are not bound by the component vocabulary. Any markup, any animation runtime, any layout technique the bundle rules allow. What you give up is the constraint being removed entirely: it is traded for a smaller set of rules, collected in the last section.

Two authoring requirements are easy to miss. The bundle must fill its frame, which means sizing to 100% width and height with `margin: 0; padding: 0` and no hardcoded pixel dimensions. And `index.html` must contain a `<head>`, because RevenueCat injects its content SDK there at upload time. You do not add that script tag yourself.

One more thing about placement. A custom component's visibility can be varied by condition through the override system, but its size cannot. Since framing depends on the ratio of the box to the animation, pick a size that frames acceptably in every configuration you support.

The CSP rule is the one to internalize first, because nearly every Rive and Lottie tutorial loads its animation by URL or file path. A bundle cannot fetch anything at display time, so both examples below are built around handing the animation data to the runtime directly.

## **Rive: A state machine driven character**

Rive is a runtime for interactive vector animation. What distinguishes it from most animation formats is the state machine: rather than playing a fixed timeline, a `.riv` file defines states and transitions, so a character can idle, react, and settle back. Files are compact, usually tens of kilobytes.

The bundle for the Marty example is six files:

```
rive-03-marty/
├── index.html
├── styles.css
├── app.js
├── config.js
├── rive.js
└── riv-data.js
```

`rive.js` is the Rive runtime and `riv-data.js` holds the `.riv` file as a base64 string. `config.js` carries the per animation settings, which lets one template serve several bundles. The snippets below inline those values instead, so each one reads on its own.

The interesting part of `index.html` is the script order:

```
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="./styles.css">
</head>
<body>
  <div id="stage"><canvas id="canvas"></canvas></div>

  <script src="./rive.js"></script>
  <script src="./riv-data.js"></script>
  <script src="./app.js"></script>
</body>
</html>
```

Every script is a file reference, since inline blocks are rejected. The stylesheet is what gives the stage a height, and without it a canvas collapses to nothing:

```
html, body { margin: 0; padding: 0; width: 100%; height: 100%; }
#stage { width: 100%; height: 100%; }
#canvas { display: block; width: 100%; height: 100%; }
```

Two decisions are specific to shipping Rive this way. The first is which Rive build to use. The default `@rive-app/canvas` build fetches its WebAssembly from a CDN at startup, which the CSP blocks, so use `@rive-app/canvas-single` instead. That build inlines the WebAssembly into the JavaScript file, which is also why it is around 1.8 MB.

The second is how the animation reaches the runtime. Rive's `src` option takes a path, and loading it triggers a fetch. So `riv-data.js` carries the bytes as base64:

```
window.__RIV_B64 = "UklWRQ...";
```

You generate that from the .riv file with one command:

```
printf 'window.__RIV_B64 = "%s";\n' "$(base64 -i marty.riv)" > riv-data.js
```

In app.js, decoding base64 back to bytes uses atob, which returns a string of character codes that you copy into a typed array:

```
var bin = atob(window.__RIV_B64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) {
  bytes[i] = bin.charCodeAt(i);
}
```

Those bytes go to the runtime through buffer rather than src. Fit and Alignment control framing, which the next section covers:

```
var r = new window.rive.Rive({
  buffer: bytes.buffer,
  canvas: document.getElementById('canvas'),
  autoplay: true,
  layout: new window.rive.Layout({
    fit: window.rive.Fit.Cover,
    alignment: window.rive.Alignment.TopCenter
  }),
  onLoad: function () { start(r); }
});
```

onLoad fires once the file is parsed, which is the first point where you can ask what the file actually contains. That matters because autoplay only covers files holding a single linear animation. When a file has a state machine, autoplay starts one of the linear clips instead of the intended behavior, so you stop that playback and start the machine:

```
function start(r) {
  r.resizeDrawingSurfaceToCanvas();
  var machine = (r.stateMachineNames || [])[0];
  if (machine) {
    r.stop();
    r.play(machine);
  }
}
```

resizeDrawingSurfaceToCanvas() matches the canvas backing store to its display size, so the result stays sharp on high density screens.

![](https://cdn.sanity.io/images/c3qnx9b0/production/522a548d1a1b325207cf3fda725117b9e5916bcd-340x340.gif)

### **Fit and alignment: Matching the artboard to the box**

Framing is the detail that needs attention. A Rive artboard, the fixed size canvas the animation was authored on, has its own aspect ratio, and the box the paywall gives you probably has a different one. With `Fit.Contain` the artboard is scaled to fit inside the box, which leaves transparent bars on two sides. If the artboard has its own background, those bars read as unwanted padding.

Which setting is right depends on the artboard:

- **Transparent artboard**: keep `Contain`. The bars are invisible against the paywall background, which is what you want for a mascot sitting on your own backdrop.
- **Artboard with its own background**: use `Cover` so the artboard fills the box, and set `alignment` to control what gets cropped.
That second point matters for characters. `Cover` crops to fill, and the default center alignment crops evenly from both edges, which in a wide box takes the top of the head. Marty uses `Cover` with `TopCenter`, so a wide box crops the legs and keeps the face.

## **Lottie: A vector animation from a JSON document**

Lottie takes a different approach. A Lottie animation is a JSON document describing shape layers, transforms, and keyframes, and the runtime interprets it into vector output. There is no state machine, just a timeline you play, loop, or seek.

The Premium Crown bundle mirrors the Rive one:

```
lottie-02-premium-crown/
├── index.html
├── styles.css
├── app.js
├── config.js
├── lottie.min.js
└── animation.js
```

Here lottie.min.js is the Lottie runtime and animation.js holds the animation JSON. Two notes on those files. The runtime is the light build, which ships without the expression evaluator and therefore without the eval call the full build contains, making it the one to pick for a bundle. And the markup differs slightly from the Rive case, since the SVG renderer needs a plain container rather than a canvas:

```
<body>
  <div id="stage"><div id="host"></div></div>

  <script src="./lottie.min.js"></script>
  <script src="./animation.js"></script>
  <script src="./app.js"></script>
</body>
```

The self containment question has a simpler answer on this side. loadAnimation accepts either a path to fetch or an animationData object already in memory, so animation.js assigns the JSON to a global:

```
window.__ANIM = { "v": "5.12.2", "fr": 60, "w": 512, "h": 512, "layers": [] };
```

And app.js hands it straight over:

```
var anim = window.lottie.loadAnimation({
  container: document.getElementById('host'),
  renderer: 'svg',
  loop: true,
  autoplay: true,
  animationData: window.__ANIM,
  rendererSettings: {
    preserveAspectRatio: 'xMidYMid meet',
    progressiveLoad: false
  }
});
```

`preserveAspectRatio` is the Lottie equivalent of Rive's fit setting, and it takes standard SVG values. `xMidYMid meet` behaves like `Contain` and fits the whole animation inside the box. `xMidYMid slice` behaves like `Cover` and fills the box, cropping the overflow, which is what you want for a full bleed animated background.

One habit to adopt for either runtime: pause when the paywall is not visible, so a looping animation is not drawing frames the user cannot see. The event fires when the paywall is dismissed or the app goes to the background:

```
document.addEventListener('visibilitychange', function () {
  if (document.hidden) {
    anim.pause();
  } else {
    anim.play();
  }
});
```

For Rive the same handler calls r.pause() and r.play() on the instance from the constructor above.

![](https://cdn.sanity.io/images/c3qnx9b0/production/97e7fe75b0030576f9be80e6149b254c14b1585a-340x340.gif)

he two bundles land in very different places on size. The crown zip is about 49 KB and the Marty zip about 660 KB. Both carry their own runtime, so the difference is the runtime itself: the Rive WebAssembly build is roughly ten times the size of Lottie's JavaScript renderer.

## **Uploading in the dashboard**

Zip the *contents* of the folder, not the folder. `index.html` has to be at the root of the archive, and this is the most common thing to get wrong. On macOS, selecting the folder and choosing Compress produces the nested layout, so zip from inside the folder instead:

```
cd rive-03-marty && zip -r ../rive-03-marty.zip . -x ".*" -x "__MACOSX/*"
```

The exclusions matter because Finder and the shell both like to add .DS_Store and __MACOSX entries, which count against the file limit and put things in your bundle you did not intend. Verify the layout before uploading:

```
unzip -l rive-03-marty.zip
```

If the listing shows `index.html` rather than `rive-03-marty/index.html`, the archive is right. Upload validation also rejects nested archives, duplicate paths, absolute paths, and `..` in entry paths.

Then add a custom component to your paywall, upload the zip, and position and size it in the tree.

To check your work before uploading, the starter template in the docs includes a preview script, `bash scripts/preview.sh`, which serves the folder locally and frames it at phone size. Opening `index.html` directly in a browser is quicker and will tell you whether the animation parses and plays, but it enforces none of the bundle rules, so a page using `fetch()` or an inline script will work locally and fail on the paywall.

## **What changes in your Android code**

Almost nothing, and this is worth stating plainly because a new component type sounds like it needs new integration work. Move to `purchases-android` 10.16.0 or newer and the paywall you already present renders a custom component with no new code.

The Compose entry point is unchanged:

```
val options = PaywallOptions.Builder(dismissRequest = { finish() })
    .build()

Paywall(options)
```

The activity based path is unchanged too. The launcher takes a result handler, so the activity implements PaywallResultHandler, and it has to be created in onCreate:

```
class MainActivity : ComponentActivity(), PaywallResultHandler {

    private lateinit var launcher: PaywallActivityLauncher

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        launcher = PaywallActivityLauncher(this, this)
    }

    override fun onActivityResult(result: PaywallResult) {
        // handle purchased, cancelled, restored, error
    }
}
```

Then present it from wherever you gate your feature:

```
launcher.launch()
```

Because the definition lives on the server, adding an animation becomes a dashboard change rather than a release. You can put a Rive character in front of users, measure it, and take it back out without touching your app, once your install base is on a version that knows the type.

## **Availability and constraints**

Custom components landed in `purchases-android` 10.16.0, released on July 31, 2026, and they require Paywalls V2. A V1 template based paywall has no component tree to place one in.

That version floor is the one operational detail to plan around. An older SDK does not recognize the component and renders the component's `fallback` instead, so configure a fallback before you enable the paywall change, and treat the rollout as gated on your install base rather than instant.

The upload limits are:

- Compressed zip: 5 MB
- Total uncompressed: 20 MB
- Single file: 10 MB
- Number of files: 200
Rive bundles are the ones to size check, though not for the reason you might expect. Marty's `.riv` is 30 KB, and base64 encoding inflates it to 40 KB, so almost all of the 660 KB zip is the runtime. Since the runtime is a fixed 1.8 MB uncompressed, the cap that binds first is the 20 MB uncompressed total, not the 5 MB zip.

The bundle rules follow from the Content Security Policy, and they rule out the shape most tutorials use:

- **Reference scripts as files.** Use `<script src="./app.js">`. Inline script blocks are rejected, even a single line of bootstrap.
- **No **`**eval()**`** or **`**new Function**`**.** Some library builds include an expression evaluator that uses them. Pick the build without it.
- **No runtime fetching.** No `fetch()`, no `XMLHttpRequest`, no `importScripts()`, and no `http:` resources. HTTPS images referenced from `<img>` or CSS do work, but bundling them removes the dependency.
- **Relative paths only.** Reference sibling files as `./file.js`.
- **Keep purchase UI native.** Packages and purchase buttons stay on real paywall components.
## **Conclusion**

Reach for a custom component when the content is the point and the vocabulary cannot express it: a branded character, a celebration on trial start, an animated backdrop. For a fixed clip, the `video` component is less work. For anything the tree already covers, use the tree, since it gets you native layout, localization, and the typed purchase path for free. When you do build a bundle, let the no fetching rule drive the design and most of the remaining decisions answer themselves.

What makes this worth the setup is not the animation but where the decision now lives. An animation used to be a code change, which meant a release, a review, and a staged rollout before you learned anything. Moving that box to the server turns it into something you can try on Monday and reverse on Tuesday, and that shortened loop tends to matter more than any single animation you put in the box.
