DEV Community

Cover image for Embedding lottie-web in React without wrecking your bundle (notes from building a previewer)
Scofield
Scofield

Posted on

Embedding lottie-web in React without wrecking your bundle (notes from building a previewer)

A designer sends you hero-animation-final-v3.json on Slack. You want to know one thing: what does it look like? Opening it in an editor gets you ten thousand lines of bezier coordinates. The online viewers either want an account or quietly keep your file. And you probably don't have After Effects installed.

So I built a small previewer that runs entirely in the browser: drop a Lottie JSON in, it plays, you get playback controls and a metadata panel. It's free and there's no signup: launchvault.dev/lottie-preview

The tool itself is intentionally boring. What turned out to be interesting was embedding lottie-web in a React app correctly. I got several things wrong before I got them right, and the docs don't really cover any of this, so here are the notes I wish I'd had.

1. You probably want lottie_light, not lottie-web

The default lottie-web build ships an expressions engine — basically an embedded JavaScript evaluator for After Effects expressions. Most animations in the wild never use expressions, and the engine roughly doubles the player's payload.

The light build is a separate entry point in the same package:

const lottie = (await import("lottie-web/build/player/lottie_light")).default
Enter fullscreen mode Exit fullscreen mode

That one line cut my player bundle roughly in half. The light build only does SVG rendering, but if you were going to use SVG anyway (and for a previewer you were), you lose nothing.

There's a second reason to prefer it that nobody mentions: if you're rendering JSON you didn't author, the expressions engine is an eval surface. A Lottie file with expressions is a file that can execute code in your page. lottie_light doesn't evaluate expressions at all, so an entire class of "malicious animation file" problems just doesn't exist. For a tool whose whole job is loading random files from strangers, that's not a micro-optimization, it's the correct default.

The await import(...) part matters too. The player only loads when someone actually previews an animation — visitors who land on the page and bounce never download it. With Next.js this Just Works; the dynamic import becomes its own chunk.

2. lottie-web's lifecycle does not match React's

lottie-web is an imperative library from a pre-hooks world. It wants a DOM node, it mutates it, and it holds internal state (timers, the SVG tree) that React knows nothing about. The naive integration leaks animations or renders two copies on top of each other.

Here's the shape that ended up working:

const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<AnimationItem | null>(null)

useEffect(() => {
  if (!animationData || !containerRef.current) return

  let cancelled = false

  const load = async () => {
    const lottie = (await import("lottie-web/build/player/lottie_light")).default
    if (cancelled || !containerRef.current) return

    animationRef.current?.destroy()
    animationRef.current = null
    containerRef.current.innerHTML = ""

    const animation = lottie.loadAnimation({
      container: containerRef.current,
      renderer: "svg",
      loop,
      autoplay: true,
      animationData,
    })
    animation.setSpeed(speed)
    animationRef.current = animation
  }

  void load()

  return () => {
    cancelled = true
    animationRef.current?.destroy()
    animationRef.current = null
  }
}, [animationData])
Enter fullscreen mode Exit fullscreen mode

Three details that each fixed a real bug:

  • The cancelled flag. The import is async. If the user drops a second file before the first one's import resolves, the stale effect run would otherwise mount a second animation into the same container. The flag makes the stale run bail after the await.
  • destroy() before loadAnimation(), and clearing innerHTML. lottie-web appends to the container; it doesn't replace. Without the cleanup you get stacked SVGs and orphaned animation timers eating CPU.
  • destroy() in the effect cleanup. Otherwise the animation keeps ticking after the component unmounts. You won't see it; your users' battery will.

3. Don't remount the animation when a setting changes

My first version had loop and speed in the effect's dependency array. Technically correct, exhaustive-deps was happy, and the UX was awful: touch the speed slider and the animation tears down, re-imports, and restarts from frame zero.

AnimationItem lets you change both in place, so the fix is two tiny effects and a deliberate lint suppression on the main one:

useEffect(() => {
  if (animationRef.current) animationRef.current.loop = loop
}, [loop])

useEffect(() => {
  animationRef.current?.setSpeed(speed)
}, [speed])
Enter fullscreen mode Exit fullscreen mode

The main effect depends only on animationData — the animation remounts when the animation changes, and nothing else. This is the general pattern for any imperative library in React: separate "create/destroy" dependencies from "reconfigure" dependencies, and keep them in separate effects. exhaustive-deps is a heuristic, not a law; this is one of the places you override it on purpose (with a comment saying why).

4. Validate the shape before you render

Any string can claim to be a Lottie file. People paste API responses, package.json, half a file. JSON.parse succeeding tells you nothing.

The cheap check that catches nearly everything: a real Lottie export has a version string (v) and a layers array.

function isLottieAnimation(data: unknown): data is LottieData {
  if (typeof data !== "object" || data === null) return false
  const d = data as Record<string, unknown>
  return typeof d.v === "string" && Array.isArray(d.layers)
}
Enter fullscreen mode Exit fullscreen mode

Two lines of duck typing, and the difference between a useful error ("this JSON is valid but doesn't look like a Lottie animation") and a silently blank canvas. I also distinguish the two failure modes — JSON.parse threw vs. parsed-but-not-Lottie — because they need different error messages. "Invalid JSON" sends the user to check for a truncated paste; "not a Lottie animation" sends them to check they grabbed the right file.

5. The metadata is just sitting there in the JSON

This was the pleasant surprise. A Lottie file's top-level keys are terse but readable, and you can build a useful inspector panel without the player's help:

Key Meaning
v exporter version
w, h dimensions in px
fr frame rate
ip in point (start frame)
op out point (end frame)
nm animation name
layers the layers
assets embedded assets (images)

Duration isn't stored directly, but it's just frames over frame rate:

const duration = (data.op - (data.ip ?? 0)) / data.fr
Enter fullscreen mode Exit fullscreen mode

Layer count and asset count are the two numbers worth surfacing. A "simple" checkmark animation with 90 layers and 12 embedded PNGs is not a simple animation — it's a rendering cost your users pay on every page load. Seeing those numbers before the file lands in your bundle turns "this feels heavy" into "can we get this under 20 layers?", which is a much better conversation to have with a designer.

6. Assorted small things

The checkerboard background is one line of CSS now. No background image, no two-gradient hack:

background: repeating-conic-gradient(#94a3b833 0% 25%, transparent 0% 50%) 50% / 20px 20px;
Enter fullscreen mode Exit fullscreen mode

Why a background switcher at all? Because an animation that looks perfect on a designer's white artboard can be literally invisible on your dark-mode UI. Transparent / white / light / dark, one click each. It's the feature I use most.

Reset the file input after reading it:

const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
  const file = event.target.files?.[0]
  event.target.value = ""
  if (!file) return
  await processFile(file)
}
Enter fullscreen mode Exit fullscreen mode

Without event.target.value = "", selecting the same file twice does nothing — onChange doesn't fire because the value didn't change. Classic, easy to miss, annoying to debug, because "upload the same file again after a failed attempt" is exactly what users do.

Everything stays in the browser. Files are read with file.text(), parsed locally, rendered locally. There's no upload endpoint at all, which is both the privacy story (your client's unannounced mascot animation never touches my server) and, honestly, the cheap-to-host story. The two are nicely aligned. You can verify it from the network tab.

What it doesn't do

No dotLottie (.lottie) support yet — that's a zip container, JSON only for now. No fetching from URLs (deliberate: the page makes no network requests on your behalf). No editing — it's a previewer, and color swapping is a different tool's job.

Try it

launchvault.dev/lottie-preview — free, no account, nothing stored.

It's one of a handful of free tools I'm building alongside LaunchVault, an open-source Product Hunt alternative. If you feed it a Lottie file that breaks it, tell me — the 2 MB limit, drag-and-drop, and the background switcher all exist because someone hit an edge I hadn't.

Top comments (0)