Fix: Lottie Not Working — Animation Not Playing, File Not Loading, or React Component Blank
Part of: React & Frontend Errors
Quick Answer
How to fix Lottie animation issues — lottie-react and lottie-web setup, JSON animation loading, playback control, interactivity, lazy loading, and performance optimization.
The Problem
The Lottie animation component renders but shows nothing:
import Lottie from 'lottie-react';
import animationData from './animation.json';
function MyAnimation() {
return <Lottie animationData={animationData} />;
// Empty div — no animation visible
}Or the animation file fails to load:
Error: Cannot find module './animation.json'
// Or: Failed to parse JSONOr the animation plays once and stops:
Animation runs through once then disappearsWhy This Happens
Lottie renders After Effects animations exported as JSON via the Bodymovin plugin. The React wrapper has specific requirements:
- The animation JSON must be valid Lottie format — not every JSON file is a Lottie animation. It must be exported from After Effects using Bodymovin or created with tools like LottieFiles. Invalid JSON or a non-Lottie file renders nothing.
- The container needs dimensions — Lottie fills its parent container. If the parent has zero width or height, the animation is invisible. Either set dimensions on the Lottie component or on its parent.
loopdefaults totrueinlottie-reactbutfalseinlottie-web— depending on which library you use, the default loop behavior differs. An animation that plays once and stops hasloop: false.- Large Lottie files impact performance — complex animations with many layers, expressions, or embedded images create large JSON files that slow down parsing and rendering. Lazy loading is important for performance.
The blank-animation case is almost always about container sizing. Lottie renders into an SVG (by default) and that SVG sizes itself to fill the parent element. If the parent is a div with no explicit width or height and no flex/grid context that gives it size, the SVG measures zero and you see nothing. The render path is correct; the geometry is wrong. The fix is mechanical — give the Lottie component an explicit style={{ width, height }} — but the diagnosis takes a beat because Chrome DevTools shows the <svg> element present in the DOM with content inside it.
The “plays once and stops” case has a subtler explanation. There are at least three Lottie libraries in common use: lottie-web (the canonical engine), lottie-react (a React wrapper), and @lottiefiles/react-lottie-player (an older wrapper). Each defaults differently — lottie-react defaults loop to true, lottie-web defaults to false. Copy-pasting a snippet from one ecosystem into a project using the other produces silently wrong behavior. The fix is always to set loop explicitly.
The performance failure mode is the one that hurts in production. A designer exports a hero animation from After Effects without optimization, the JSON is 800 KB, and the rendering pipeline allocates hundreds of SVG nodes per frame. On a low-end Android device, you get sub-15 FPS jank that propagates to scroll and input handlers. The first signal is usually a customer support ticket about “the page feels slow on my phone” — not “the animation is broken.” Treat large Lottie JSON like an oversized image: ask if it’s necessary, compress it, lazy-load it, and consider whether a CSS animation or a static SVG would deliver the same brand value.
Production Incident Lens: When Brand Polish Breaks
The blast radius of a broken Lottie is small in functional terms — your app still works without animations — but disproportionate in perceived quality terms. A marketing site with a stuck hero animation looks broken even if every other element works. A loading spinner that fails to render makes the page feel hung even when the data is loading normally. A like-button micro-interaction that doesn’t animate makes users tap twice, thinking the first tap was lost.
The on-call signal pattern is:
- No error in the console, no failed request in the network tab — Lottie failures rarely throw. The JSON fetches, the component mounts, and the user sees a blank box. Synthetic monitoring won’t catch it because every metric reads green.
- RUM “perceived slowness” complaints — Lottie on the critical render path on a mobile device can add 200–800 ms of jank that no Core Web Vital captures cleanly. INP gets close but doesn’t attribute the blame.
- Cumulative Layout Shift — if you don’t set explicit dimensions on the Lottie container, the animation pops into existence after JSON parse and shifts surrounding content. CLS regressions tied to a specific component are a strong fingerprint.
The mitigation patterns are: render a static fallback (a PNG or a CSS animation) while Lottie loads, use Intersection Observer to defer below-the-fold animations, and add a feature flag that disables animations entirely. Disabled animations look austere but feel fast; broken animations look broken. Pick fast over polished when you have to choose. For high-traffic pages, consider dotLottie (.lottie) format — it’s a zip of the JSON plus assets and typically 40–60% smaller. The smaller payload reduces the window during which Lottie can fail.
Fix 1: Basic Setup with lottie-react
npm install lottie-react
# Or for lower-level control:
# npm install lottie-web'use client';
import Lottie from 'lottie-react';
import loadingAnimation from '@/animations/loading.json';
// Basic usage — plays automatically, loops by default
function LoadingSpinner() {
return (
<Lottie
animationData={loadingAnimation}
loop={true}
style={{ width: 200, height: 200 }} // Set explicit size
/>
);
}
// With playback control
import { useRef } from 'react';
import type { LottieRefCurrentProps } from 'lottie-react';
function ControlledAnimation() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
return (
<div>
<Lottie
lottieRef={lottieRef}
animationData={loadingAnimation}
loop={false}
autoplay={false} // Don't play on mount
style={{ width: 300, height: 300 }}
/>
<div>
<button onClick={() => lottieRef.current?.play()}>Play</button>
<button onClick={() => lottieRef.current?.pause()}>Pause</button>
<button onClick={() => lottieRef.current?.stop()}>Stop</button>
<button onClick={() => {
lottieRef.current?.goToAndStop(0, true); // Reset to first frame
lottieRef.current?.play();
}}>
Replay
</button>
<button onClick={() => lottieRef.current?.setSpeed(2)}>2x Speed</button>
<button onClick={() => lottieRef.current?.setDirection(-1)}>Reverse</button>
</div>
</div>
);
}Fix 2: Load Animation from URL
'use client';
import Lottie from 'lottie-react';
import { useEffect, useState } from 'react';
// Load from URL instead of importing JSON
function RemoteAnimation({ url }: { url: string }) {
const [animationData, setAnimationData] = useState<object | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setAnimationData)
.catch(err => console.error('Failed to load animation:', err));
}, [url]);
if (!animationData) return <div>Loading...</div>;
return (
<Lottie
animationData={animationData}
loop
style={{ width: 300, height: 300 }}
/>
);
}
// Usage
<RemoteAnimation url="https://lottie.host/xxx/animation.json" />
<RemoteAnimation url="/animations/success.json" />Fix 3: Interactive Animations (Hover, Scroll, Click)
'use client';
import Lottie from 'lottie-react';
import { useRef, useState } from 'react';
import type { LottieRefCurrentProps } from 'lottie-react';
import heartAnimation from '@/animations/heart.json';
// Play on hover
function HoverAnimation() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
return (
<div
onMouseEnter={() => lottieRef.current?.play()}
onMouseLeave={() => {
lottieRef.current?.goToAndStop(0, true);
}}
>
<Lottie
lottieRef={lottieRef}
animationData={heartAnimation}
loop={false}
autoplay={false}
style={{ width: 80, height: 80, cursor: 'pointer' }}
/>
</div>
);
}
// Toggle animation (like button)
function LikeButton() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const [liked, setLiked] = useState(false);
function handleClick() {
if (liked) {
lottieRef.current?.goToAndStop(0, true);
} else {
lottieRef.current?.goToAndPlay(0, true);
}
setLiked(!liked);
}
return (
<button onClick={handleClick} style={{ background: 'none', border: 'none' }}>
<Lottie
lottieRef={lottieRef}
animationData={heartAnimation}
loop={false}
autoplay={false}
style={{ width: 60, height: 60 }}
/>
</button>
);
}
// Scroll-driven animation
function ScrollLottie() {
const containerRef = useRef<HTMLDivElement>(null);
const lottieRef = useRef<LottieRefCurrentProps>(null);
useEffect(() => {
function handleScroll() {
if (!containerRef.current || !lottieRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const scrollProgress = Math.max(0, Math.min(1,
(window.innerHeight - rect.top) / (window.innerHeight + rect.height)
));
const totalFrames = lottieRef.current.getDuration(true) || 0;
lottieRef.current.goToAndStop(scrollProgress * totalFrames, true);
}
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div ref={containerRef} style={{ height: '200vh' }}>
<div style={{ position: 'sticky', top: '20%' }}>
<Lottie
lottieRef={lottieRef}
animationData={animationData}
autoplay={false}
loop={false}
style={{ width: 400, height: 400 }}
/>
</div>
</div>
);
}Fix 4: Using lottie-web Directly
'use client';
import lottie, { type AnimationItem } from 'lottie-web';
import { useEffect, useRef } from 'react';
function LottieWeb({ path, loop = true, autoplay = true }: {
path: string;
loop?: boolean;
autoplay?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<AnimationItem | null>(null);
useEffect(() => {
if (!containerRef.current) return;
animationRef.current = lottie.loadAnimation({
container: containerRef.current,
renderer: 'svg', // 'svg' | 'canvas' | 'html'
loop,
autoplay,
path, // URL to JSON file
// Or: animationData: jsonObject,
});
// Events
animationRef.current.addEventListener('complete', () => {
console.log('Animation completed');
});
animationRef.current.addEventListener('loopComplete', () => {
console.log('Loop completed');
});
return () => {
animationRef.current?.destroy();
};
}, [path, loop, autoplay]);
return <div ref={containerRef} style={{ width: 300, height: 300 }} />;
}
// Use canvas renderer for better performance
lottie.loadAnimation({
container: element,
renderer: 'canvas', // Faster for complex animations
loop: true,
autoplay: true,
animationData: data,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
clearCanvas: true,
progressiveLoad: true,
},
});Fix 5: dotLottie (Smaller Files)
npm install @lottiefiles/dotlottie-react'use client';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
// .lottie files are compressed — much smaller than .json
function DotLottieAnimation() {
return (
<DotLottieReact
src="/animations/loading.lottie" // .lottie format
loop
autoplay
style={{ width: 300, height: 300 }}
/>
);
}
// From LottieFiles URL
function RemoteDotLottie() {
return (
<DotLottieReact
src="https://lottie.host/xxx/animation.lottie"
loop
autoplay
/>
);
}Fix 6: Performance and Lazy Loading
'use client';
import dynamic from 'next/dynamic';
import { Suspense, lazy } from 'react';
// Lazy load Lottie component — don't include in initial bundle
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
function LazyAnimation() {
const [animationData, setAnimationData] = useState(null);
useEffect(() => {
// Load animation data only when component mounts
import('@/animations/hero.json').then(mod => setAnimationData(mod.default));
}, []);
if (!animationData) return <div style={{ width: 400, height: 400 }} />;
return (
<Lottie
animationData={animationData}
loop
style={{ width: 400, height: 400 }}
/>
);
}
// Intersection Observer — only load when visible
function LazyVisibleAnimation({ src }: { src: string }) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' },
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (isVisible) {
fetch(src).then(r => r.json()).then(setData);
}
}, [isVisible, src]);
return (
<div ref={ref} style={{ width: 300, height: 300 }}>
{data && <Lottie animationData={data} loop />}
</div>
);
}Still Not Working?
Animation renders as empty div — the container has zero dimensions. Set style={{ width: 300, height: 300 }} on the Lottie component, or ensure the parent container has defined dimensions. Lottie SVGs fill the container.
JSON file not found or parse error — place animation files in public/animations/ for URL loading, or in src/animations/ for import. Files in public/ are served as-is at /animations/file.json. Imported JSON files are bundled into your JavaScript.
Animation plays once and stops — set loop={true}. In lottie-react, loop defaults to true, but if you’ve set loop={false} or are using lottie-web directly (which defaults to false), the animation won’t repeat.
Large animation causes jank — complex Lottie files can have hundreds of layers. Use the canvas renderer instead of SVG for better performance: renderer: 'canvas' in lottie-web. Reduce the animation’s dimensions and complexity in After Effects. Consider .lottie format which is compressed.
Animation respects prefers-reduced-motion incorrectly — Lottie does not auto-pause for users who set prefers-reduced-motion: reduce. Add the check yourself: read window.matchMedia('(prefers-reduced-motion: reduce)').matches and pass autoplay={false} when true. Show a static poster frame instead. Accessibility audits flag missing motion gates as serious failures.
CLS regression after adding a hero Lottie — the animation container shifts height after JSON loads. Reserve the space upfront with width and height (or aspect-ratio) on the placeholder, and only swap in the Lottie component once data is ready. Layout-shift attribution in Chrome DevTools points directly to the unsized container.
Mobile Safari freezes on a complex animation — Safari’s SVG renderer is slower than Chrome’s for animations with hundreds of layers. Switch to renderer: 'canvas' for those animations, or pre-render to a video and use <video autoplay muted playsinline loop> instead. Some animations don’t need to be Lottie.
For related animation issues, see Fix: GSAP Not Working, Fix: Framer Motion Not Working, Fix: React Three Fiber Not Working, and Fix: Webpack Bundle Too Large.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.