Put an Original Vector Animation Into a Web Treatment With Lottie
Put an Original Vector Animation Into a Web Treatment With Lottie
A Lottie export succeeds the moment the exporter writes a file. That's a low bar, and it isn't the one your treatment page has to clear. The file can be completely valid and still arrive without the soft shadow that made your insert read as a separate object, without the photo it points at, and with no way for a reader to stop it moving.
The job, then, isn't "export and embed." It's four things agreeing: the source features your chosen exporter and renderer actually carry, the assets that live outside the data, the controls you hand the reader, and the state the page falls back to when something is missing.
We'll work through that with one study. The study is invented for this walkthrough, and nothing in it has been exported, loaded in a browser, resized, or tested. That's on purpose: what follows is the sequence of checks, and every number here should be re-measured against your own files, your own release, and your own reading conditions.
The study: a folding carton, 3 seconds at 30 fps, authored at 800 × 600. The carton's flap opens at around 1.2 seconds and an insert rises out of it. The insert carries a raster product photo, its own soft shadow that separates it from the carton's inner wall, and a short line of text on the flap. That's a small piece of animation, and it already contains all four kinds of dependency you'll meet in a larger one.
Keep a reference and audit one source study
Before you export anything, render a reference. Take the native composition, render it at 800 × 600 and 30 fps, and keep the file — carton-reference.mp4 if you like the name. That movie is your only trustworthy record of what the study is supposed to look like. Once the native project is closed, the reference is the thing you compare the browser against, and without it "the export looks fine" means nothing in particular.
Next to it, write down the route. Source application and version. Exporter and version. Renderer — for this walkthrough, SVG. Browser and version. Four lines, kept with the reference. When a reviewer asks in three weeks why the insert lost its shadow, these lines are the answer, and when one of the four changes, you know which comparisons to redo.
Then audit the study against that route's feature list. Go through the layers that carry meaning and ask what the selected exporter and renderer do with each: shape layers, masks, blend modes, text, layer styles, imported images. The lottie-web README (https://github.com/airbnb/lottie-web) lists feature limits, and the project's Features wiki separates support by renderer — SVG, canvas, and HTML — and provides a way to flag source features the format can't represent. That wiki page shows a 2020 edit date. Treat it as a good set of questions to ask of your release, not as a verdict on it.
For this walkthrough, stipulate the outcome of that audit: the one element that does not survive is the insert's soft shadow, authored as a layer style. Redo the audit with your own file and your own release, because the element that fails may be a different one. The method holds either way.
Why the shadow matters is the whole point of the audit. It is what makes the insert read as a separate plane rather than a printed panel on the carton's inner wall. Delete it and the drawing says something slightly different, which is exactly the kind of quiet change an export makes easy. So you have a real choice: approximate the shadow with vector shapes — a few stacked, low-opacity offsets that survive the SVG renderer — and compare the result against the reference; or keep the movie for that beat and say so. What you don't do is delete it to make the export pass.
Export the animation and identify what lives outside it
A Lottie file is animation data. It stores geometry, transforms, opacity and keyframes as numbers, and it names, by reference, whatever it doesn't draw itself. That's the thing to hold onto, because it means your deliverable is at least four pieces:
The data. A JSON file. Portable, small, and silent about whether it looks right.
The player. Library code that reads the JSON and draws it. Not inside the JSON. The README documents loading exported data with a container, a renderer and playback settings; that call is the player's job, not the file's.
The assets. Images and fonts. The README's feature notes state that audio, video, and image sequences are not supported as those sources — so a video clip or a rendered raster sequence sitting in the native timeline does not travel inside the data as playable media. Your insert photo is not in the JSON either. The data names it; you serve it; those two facts have to agree.
The page. The container element, its size, your controls, your fallback.
Keep the asset paths explicit and boring. Put the images in one folder beside the JSON, refer to them relatively, and keep a list of every asset the file names, because that list is what you'll check when one goes missing. It does a second job too: it's the list the page probes at load, so that a missing image can be reported as a missing image rather than as a dead animation. If your renderer settings offer a path prefix for image assets, use it deliberately rather than letting the file's stored names and your server paths quietly diverge. Check your release's documentation for the exact key and behavior.
Permission is separate from any of this. A third-party photo, a licensed font, an unreleased product shot: the exporter will happily reference all of them. Whether they can ship is a decision made before the handoff, not a setting in the export dialog. If an image can't travel, that's a content decision — replace it with authorized material or drop the beat — not an export trick.
Fonts deserve their own line, because text can surprise you. Depending on the exporter's settings, text may arrive as glyph outlines or as a reference the browser has to satisfy. Outlines are faithful and rigid; a font reference is flexible and depends on the reader's environment. Find out which one your file uses, because it decides whether the flap's text is a fixed drawing or a rendering request. If the text carries meaning — an instruction, a product name — and the route can't reproduce it reliably, it belongs in the fallback explanation instead.
Here is the distinction that causes the most confusion later: the data loading is not the same as the assets loading. A treatment element can show a carton that opens and closes around an empty insert frame. The data arrived. The photo didn't. That is a partial state, not a success, and you have to go looking for it on purpose.
Mount a controlled player in a bounded page
One animation, one named container, a small number of explicit choices. That's the whole integration.
<figure class="study">
<div id="carton-study" class="study__stage"></div>
<img id="poster" class="study__poster"
src="carton-poster.png"
alt="The closed carton, before the insert is revealed.">
<figcaption>The carton opens to reveal the insert that carries the product photo.</figcaption>
</figure>
<button id="toggle" type="button" aria-pressed="false" disabled>Play</button>
<button id="final" type="button" disabled>Show final state</button>
<p id="state" role="status">Loading the carton study…</p>
<script src="lottie.min.js"></script>
The stage is a plain box with a width and an aspect-ratio of 4 / 3. The poster sits in the same box, absolutely positioned and visible by default, so the page never jumps and a reader who gets nothing else still gets a labeled still. The explanation lives in the figcaption, not in an alt attribute on the animation. A reader who never sees the animation should still come away with the sentence that says what it shows.
Both buttons ship with disabled written into the markup, and the mount is what takes it off. Until the data is ready there is nothing to play, so the disabled state is the truthful one; and if the player script never arrives, nothing will ever arrive to enable them, so the page has to be correct before any of its script runs. The status paragraph starts at "Loading the carton study…", and the mount's job is to move it to something terminal — ready, failed, or a named state. "Loading…" should never be the last thing the page says.
Now the mount:
const container = document.getElementById("carton-study");
const toggle = document.getElementById("toggle");
const finalBtn = document.getElementById("final");
const state = document.getElementById("state");
const poster = document.getElementById("poster");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const assets = ["carton-insert-photo.png"];
let anim = null;
if (typeof lottie === "undefined") {
state.textContent = "The animation player did not load. The description above still applies.";
} else {
mount();
}
function mount() {
anim = lottie.loadAnimation({
container: container,
renderer: "svg",
loop: false,
autoplay: false,
path: "carton-study.json"
});
anim.addEventListener("DOMLoaded", function () {
poster.hidden = true;
toggle.disabled = false;
finalBtn.disabled = false;
state.textContent = "Ready.";
if (reduce) {
anim.goToAndStop(anim.totalFrames - 1, true);
state.textContent = "Reduced motion: showing the final state.";
}
checkAssets();
});
anim.addEventListener("complete", function () {
toggle.textContent = "Replay";
toggle.setAttribute("aria-pressed", "false");
});
anim.addEventListener("error", function () {
poster.hidden = false;
state.textContent = "The animation data did not load. The description above still applies.";
toggle.disabled = true;
finalBtn.disabled = true;
});
toggle.addEventListener("click", function () {
const playing = toggle.getAttribute("aria-pressed") === "true";
if (playing) {
anim.pause();
toggle.textContent = "Play";
state.textContent = "Paused.";
} else if (anim.currentFrame >= anim.totalFrames - 1) {
anim.goToAndPlay(0, true);
toggle.textContent = "Pause";
state.textContent = "Playing.";
} else {
anim.play();
toggle.textContent = "Pause";
state.textContent = "Playing.";
}
toggle.setAttribute("aria-pressed", String(!playing));
});
finalBtn.addEventListener("click", function () {
anim.goToAndStop(anim.totalFrames - 1, true);
toggle.textContent = "Replay";
toggle.setAttribute("aria-pressed", "false");
state.textContent = "Final state: the insert revealed.";
});
}
function checkAssets() {
assets.forEach(function (src) {
const probe = new Image();
probe.addEventListener("error", function () {
state.textContent = "The data loaded, but an image did not: " + src
+ ". The carton opens onto an empty frame.";
});
probe.src = src;
});
}
The if (typeof lottie === "undefined") check at the top is not defensive padding. It is the full-failure path from the check list below. Block lottie.min.js and the name is undefined; without the guard the script throws at the first lottie.loadAnimation call, and every line after it — including the listener that would have disabled the controls — is simply never reached. With the guard, the poster stays up, the buttons stay disabled because that is how they were written, and the status line names the thing that is actually missing instead of holding at "Loading…" forever.
Read the four settings as decisions rather than defaults.
container is the named element, which means the animation can't take over the page and you can style the box.
renderer: "svg" is the same choice you made during the audit. Pick it before you export, not after, because the feature list is per renderer.
loop: false and autoplay: false are stated outright. A treatment page that starts moving on its own is a page that has decided something for the reader, and it is a page that ignores motion preferences by construction. Say what you mean in the config.
path fetches the JSON over HTTP, which means a real server, same-origin or CORS-correct, and a local file:// open will often fail for reasons that have nothing to do with your animation. Use inline animationData only when you have a reason to inline.
The controls stay small and honest:
Both controls are real buttons, so Tab, Enter and Space work without extra code, and aria-pressed tells a screen reader what the toggle currently means. The role="status" paragraph announces state changes without stealing focus. The second argument to goToAndStop says the first argument is a frame number rather than a time in seconds; check the instance's reported frame count against your native timeline instead of assuming your 3-second comp is 90 frames of player time.
The README's Events section documents load and error events, and the instance exposes playback and state methods — play, pause, stop, the seek-and-play calls, destroy — along with properties such as its current frame and total frames. Confirm the exact names against the release you installed before you build production code on them.
A control labelled Replay has to actually replay. When the animation completes, the playhead is sitting on the last frame, and calling play() from there is asking the player to invent a restart. So the toggle checks where the playhead is and, at the end, seeks back to frame 0 with goToAndPlay(0, true) before playing. Confirm on the installed release that the replay visibly restarts; a Replay button over a still picture is the same label-versus-reality mismatch the navigation check below exists to catch.
Two failures need two messages. The animation's error event means the data didn't arrive: the poster comes back, the controls go dead, and the status line says the data is what failed. A missing image is a different fact. The data is fine, the carton animates on schedule, and one referenced file isn't there — which is exactly the state the missing-asset check asks the reader to see. So an image failure must not un-hide the poster and must not be announced as a total failure; it should name the file and the empty frame. Whether your release reports a failed image through its own error event or not, the probe over assets covers it, and the list it walks is the one you wrote down at export. Run the probe after DOMLoaded, so the "Ready." message can't land on top of a failure the page already knew about.
Two more pieces of housekeeping. When you navigate away from a single-page view, destroy the instance and remove your listeners; when you come back, create a fresh one. Reusing a destroyed instance, or mounting a second one into the same container, produces the state you'll be checking for in the next section. And keep reduced motion minimal here: no autoplay under prefers-reduced-motion, and the explanatory end state reachable without motion. A fuller reduced-motion route is its own piece of work.
Compare appearance, state, and failure behavior
Now put the browser result beside the retained reference, at 800 × 600, and look at three moments: the first frame, the reveal at about frame 36, and the final frame. The final frame is the one that matters most, because in a treatment it's usually doing the explaining — the insert fully out, the photo visible, nothing cropped.
Then run the failures, one at a time, and write down what the reader gets in each.
Resize. Take the container from 800 wide to 320. A vector animation should hold, but check the specific things: the approximated shadow, which may read as a grey smear at small sizes; the flap text, which may lose its legibility; the stroke weights, which may look heavier or thinner than the reference. Scaling is not free just because the artwork is vector.
Missing asset. Rename the insert photo, or move its folder, and load the page again. What you should see is the outline animating on schedule with an empty photo area — a carton opening onto nothing — and a status line that names the file that didn't arrive. That is the difference between data loading and assets loading, and the fix is the path, not a smaller insert. Restore it, confirm the photo returns, and keep the poster fallback out of that state — an image failure deliberately leaves it down — so you can actually see the defect.
Changed text and masks. At the reveal frame, compare the flap text's position and the mask edges on the carton's inner wall against the reference. Text and masks are where an export most often looks close but isn't, and both are easy to miss when you're watching an animation rather than a still.
Replay after navigation. Let the animation reach the final state, navigate away, come back. If the instance was destroyed and recreated, you return to a fresh first frame with the toggle reading Play. If it wasn't, you may find the state carried over, or two instances drawing into the same box, or a toggle whose label no longer matches reality. Check the DOM, not just the picture. Then press Replay once from the completed state and confirm the animation actually starts over from the first frame rather than sitting on the last one under a new label.
Full failure. Block the player script itself. The poster should stay visible, the description should still be on the page, the status line should say the player didn't load rather than holding at "Loading the carton study…", and the controls should be disabled rather than sitting there doing nothing when pressed — they ship that way, so nothing has to happen for them to stay that way.
Then the choice the audit set up. Two routes exist for the carton, and they trade different things.
The vector-only route approximates the shadow with vector shapes and ships the JSON, the photo, and your controls. It stays crisp at any size, scrubs, replays, and honors reduced motion with a real end state. What it costs is fidelity: the shadow is your approximation, and you have to judge it against the reference and either accept it or rebuild it.
The movie route keeps the authored shadow exactly, because by then it's just pixels. It costs resolution independence, your controls are the video player's instead of yours, and reduced motion becomes something you have to handle in the video markup too. Label it. A short clip or a labeled still sequence sitting where the animation would be is an honest answer; calling the vector-only version a lossless conversion of the native study is not.
Assemble the review element
Everything above converges on one block on the review page, and that block is the thing a colleague or client actually evaluates. It has five parts, and if any one of them is missing, the review is really a demonstration.
The animation, mounted in its bounded container at the size readers will meet it, with the poster still in place underneath.
The controls: Play and Pause as one truthful toggle, a control that reaches the explanatory end state, both reachable by keyboard, and a status line that says which state you're looking at.
The fallback: a labeled still and a sentence that carries the explanation, visible before load and after any failure.
The reference: the retained movie beside the animation, plus the four lines recording source application, exporter, renderer, and browser versions.
The route note: one plain sentence saying which path this element takes and what it doesn't reproduce.
Open that page under reduced motion and it should show the end state, not motion. Rename the photo and it should still explain itself. Come back to it after navigating away and it should start clean. When those three hold, you have a dependency-complete element — the export was the middle of the job, not the end of it.
Frequently asked questions
What has to agree before a Lottie animation belongs on a treatment page?
Four things: the source features your chosen exporter and renderer actually carry, the assets that live outside the animation data, the controls you hand the reader, and the fallback state the page shows when something is missing. An export is only valid if it writes a file; that is not the same as a dependency-complete element.
Why keep a reference movie and route notes before exporting?
Render the native composition at the target size and frame rate and keep that file. It is your only trustworthy record of what the study should look like once the native project is closed. Keep four lines beside it: source application and version, exporter and version, renderer, and browser and version, so when one changes you know which comparisons to redo.
What lives outside the Lottie JSON?
The player library, the page container and controls, and any referenced assets such as images and fonts. The JSON stores geometry, transforms, opacity, and keyframes as numbers and names by reference what it does not draw itself. Audio, video, and image sequences are not supported as those sources, so a video clip or rendered raster sequence does not travel inside the data as playable media.
How should a missing image behave compared with a failed player or data load?
They are different failures. A missing image means the data loaded and the animation can run, but one referenced file is not there: the carton opens onto an empty frame, the status line should name the file, and the poster should stay down so the defect is visible. A data or player failure means the poster comes back, the controls stay disabled, and the status line says what failed, not that it is still loading.
What should the final review element contain?
The animation mounted in a bounded container at the size readers will meet, with the poster still underneath; controls with a truthful Play and Pause toggle, a control that reaches the explanatory end state, keyboard reachability, and a status line; a labeled still and explanation visible before load and after failure; the retained reference movie beside the animation plus the four version lines; and one plain sentence saying which route the element takes and what it does not reproduce.