I’m trying to add a Lottie vector animation to my website, but I’m unsure how to embed, control, and optimize it properly. I need guidance on the best setup for smooth playback, responsive sizing, and fast page performance.
Don’t drop a large autoplaying Lottie file into the page and assume “vector” means lightweight. Complex masks, blurs, embedded images, thousands of paths, and endless loops can use plenty of CPU, especially on mobile.
For a standard setup, lottie-web gives you direct control over loading and playback:
<div id='hero-animation' class='lottie' aria-hidden='true'></div>
.lottie {
width: min(100%, 640px);
aspect-ratio: 16 / 9;
}
.lottie svg,
.lottie canvas {
display: block;
width: 100%!important;
height: 100%!important;
}
import lottie from 'lottie-web';
const container = document.querySelector('#hero-animation');
const reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
const animation = lottie.loadAnimation({
container,
renderer: 'svg',
loop: true,
autoplay: false,
path: '/animations/hero.json',
rendererSettings: {
preserveAspectRatio: 'xMidYMid meet'
}
});
if (reducedMotion.matches) {
animation.goToAndStop(0, true);
} else {
const observer = new IntersectionObserver(([entry]) => {
entry.isIntersecting? animation.play: animation.pause;
}, { threshold: 0.25 });
observer.observe(container);
}
The animation instance has methods such as play, pause, stop, setSpeed, setDirection, goToAndStop, and playSegments. SVG is usually the easiest renderer for responsive interface animation. Canvas may be worth testing when an animation has a very large number of elements, but it is not automatically faster in every case.
Most performance gains happen before the file reaches your site. Ask whoever exports it to simplify paths, remove hidden layers, avoid heavy effects and expressions, convert text when appropriate, and keep raster assets small. Lazy-load animations below the fold, pause them when they leave the viewport, and call animation.destroy when removing the component in a single-page app. For a tiny decorative effect, CSS or a short SVG animation may be cheaper than shipping a Lottie player at all.
Respect reduced-motion settings rather than merely slowing the loop. A static first or final frame is usually a reasonable fallback. If the animation is decorative, hide it from assistive technology. If it communicates useful information, give the container an accessible label and make sure the same information exists in text.
Fix the observer callback first: it needs entry.isIntersecting? animation.play: animation.pause. Without the parentheses, it only references those methods, so scrolling may appear to do nothing. I’d also reserve the animation’s dimensions in CSS, as shown, and serve the JSON with long-term caching. That prevents layout jumps and repeat downloads, which often matter more than tweaking playback settings.
A tiny decorative icon and a full-width hero animation should not use the same setup. For most interface animations, the SVG renderer gives you clean responsive scaling. Put the player inside a wrapper with a fixed aspect-ratio, set the animation element to width: 100%; height: 100%, and avoid resizing the JSON artwork with JavaScript.
@0xowl3 is right about calling play and pause in the observer. I’d initialize with autoplay: false, then let the observer control playback. Remember to disconnect the observer and call animation.destroy if the element gets removed, especially in a single-page app. Loading the player library only when an animation is near the viewport can save more bandwidth than tweaking renderer options.
The commonly missed case is reduced motion. Check prefers-reduced-motion and show a static first or final frame instead of looping. For large or complex artwork, simplify it in After Effects before export. Masks, blur, lots of paths, and huge embedded images can make a “vector” animation surprisingly heavy, and switching to canvas only hides some of that cost.
If this is an above-the-fold hero, lazy-loading everything can make the page look worse rather than faster. Reserve the space immediately and show a lightweight poster image or static SVG frame while the player library and animation data load. For animations farther down the page, loading shortly before they enter the viewport makes more sense.
Be careful with caching too. @hackbyte3 is right that the JSON should be cached, but I would not give an unversioned file such as hero.json a year-long cache. Use a hashed filename like hero.a83f2c.json, then replace the filename when the animation changes. Otherwise some visitors may keep seeing an old version after you deploy an update. Compression helps the download, but it does nothing about the cost of parsing the file, building a large SVG tree, and animating it.
Deployment paths are another easy failure point. A Lottie export may reference separate images, and those paths can break when the JSON moves behind a CDN or into a different build directory. Cross-origin rules and a strict content security policy can cause the same sort of silent-looking failure. I would prefer a self-contained export where practical, or at least keep the JSON and its image assets together and test the production build rather than only a local development server.
For responsive sizing, let CSS control the wrapper, but decide what should happen at narrow aspect ratios. preserveAspectRatio: 'xMidYMid meet' keeps all artwork visible and may leave empty space. Using a “slice” style fit fills the box but can crop important content. That choice should be intentional, especially if text or a product image sits near an edge. I would avoid putting essential readable text inside the animation anyway, since it scales poorly and creates accessibility and localization problems.
Finally, handle loading failure as a normal case. Keep the static fallback visible until the animation reports that it is ready, then replace it. If the JSON fails, the library is blocked, or the browser struggles with it, the page should still make sense. That fallback, plus the corrected play and pause calls mentioned above, reduced-motion handling, cleanup, and versioned caching, is a safer production setup than treating the animation as content that must run at all costs.
Big JSON files stall your main thread while they parse, and that hit lands right when the page is trying to paint. @primephoenix_36 already flagged this, and it’s the part people underestimate the most. Gzip shrinks the download but the browser still has to chew through the whole thing.
If your file is large, look at the dotLottie format instead of raw JSON. It’s basically a zipped bundle with the assets included, so you dodge both the broken-image-path mess and a chunk of the transfer size in one move. Pair that with the lottie-web SVG renderer for interface stuff and you’re fine.
The one bit I’d push back on lightly is treating canvas as a last resort. For a dense hero animation with hundreds of moving paths, canvas can actually keep scrolling smoother because you’re not thrashing a giant SVG DOM. It won’t fix a heavy file, but it changes where the cost lands. Worth a quick A/B on an actual mid-range phone before you commit.
Don’t let several event handlers call play independently. The viewport observer, reduced-motion setting, tab visibility, and any pause button can easily fight each other. Then the animation restarts when the user thought they had stopped it.
Keep those conditions as state and decide playback in one function:
let inView = false;
let userPaused = false;
let ready = false;
const motion = matchMedia('(prefers-reduced-motion: reduce)');
function syncPlayback {
if (!ready) return;
const shouldPlay =
inView &&
!document.hidden &&
!motion.matches &&
!userPaused;
if (shouldPlay) {
animation.play;
} else if (motion.matches) {
animation.goToAndStop(0, true);
} else {
animation.pause;
}
}
animation.addEventListener('DOMLoaded', => {
ready = true;
syncPlayback;
});
const observer = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
syncPlayback;
}, { threshold: 0.25 });
observer.observe(container);
document.addEventListener('visibilitychange', syncPlayback);
motion.addEventListener('change', syncPlayback);
This covers a small annoyance the other setups miss: prefers-reduced-motion can change while the page is open, and an animation can keep consuming resources in a hidden tab unless you account for page visibility. If you expose controls, update userPaused from a real <button> and call syncPlayback rather than calling the player directly.
The CSS wrapper and versioned asset advice above still apply. Just avoid turning playback into a pile of unrelated callbacks. Lottie already has enough moving parts without your control code arguing with itself.
The state idea from @cybereagle2881 is sound, but that code will not run as posted. JavaScript methods need parentheses, syncPlayback needs ``, and the event callback is missing its parameter list. The observer should call animation.play and animation.pause, not merely reference them. DOMLoaded is a valid Lottie event, but it still needs a proper callback.
function syncPlayback {
if (!ready) return;
if (inView &&!document.hidden &&!motion.matches &&!userPaused) {
animation.play;
} else if (motion.matches) {
animation.goToAndStop(0, true);
} else {
animation.pause;
}
}
animation.addEventListener('DOMLoaded', => {
ready = true;
syncPlayback;
});
Watch out for duplicate initialization too. Client-side navigation, hot reload, modal reopening, or a component rerender can call loadAnimation twice on the same container. Then you get stacked SVG elements and mysterious extra CPU use. Keep the animation instance with the component, refuse to initialize it twice, and run observer.disconnect, remove your event listeners, and call animation.destroy during teardown. The destroy method clears the animation container and releases its resources.
Beyond that, keep the setup boring: CSS owns the dimensions, the player owns playback, and the page still works if the animation never loads. If you need complicated application state just to display a decorative loop, replace it with a static SVG or image.