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
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])
Three details that each fixed a real bug:
-
The
cancelledflag. 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 theawait. -
destroy()beforeloadAnimation(), and clearinginnerHTML.lottie-webappends 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])
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)
}
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
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;
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)
}
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)