Lottie Animations for Next.js
Lightweight, scalable JSON-based animations using lottie-react. Perfect for micro-interactions, loading states, icons, and hero animations.
Why Lottie?
- Tiny file sizes: Up to 600% smaller than GIFs
- Infinite scalability: Vector-based, sharp at any resolution
- Full control: Play, pause, speed, direction, segments
- Interactive: Scroll-sync, hover triggers, click events
- Cross-platform: Same JSON works on web, iOS, Android
Installation
bun add lottie-react
Version Compatibility:
- Next.js 15+/16+ with React 19: Works with dynamic imports (
ssr: false) - lottie-react wraps lottie-web, which requires browser APIs
- Note: Next.js 16 removed the
eslintkey fromnext.config.ts— use Biome instead
Next.js App Router Setup
Lottie uses browser APIs (canvas/SVG). Use dynamic imports to prevent SSR errors.
1. Create the Lottie Component
// components/lottie/lottie-animation.tsx
"use client";
import { useEffect, useRef } from "react";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
type LottieAnimationProps = {
animationData: object;
loop?: boolean;
autoplay?: boolean;
speed?: number;
direction?: 1 | -1;
className?: string;
onComplete?: () => void;
onLoopComplete?: () => void;
};
export function LottieAnimation({
animationData,
loop = true,
autoplay = true,
speed = 1,
direction = 1,
className,
onComplete,
onLoopComplete,
}: LottieAnimationProps) {
const lottieRef = useRef<LottieRefCurrentProps>(null);
useEffect(() => {
if (lottieRef.current) {
lottieRef.current.setSpeed(speed);
lottieRef.current.setDirection(direction);
}
}, [speed, direction]);
return (
<Lottie
lottieRef={lottieRef}
animationData={animationData}
loop={loop}
autoplay={autoplay}
className={className}
/>
);
}
2. Create Dynamic Import Wrapper
// components/lottie/dynamic-lottie.tsx
"use client";
import dynamic from "next/dynamic";
export const DynamicLottie = dynamic(
() => import("./lottie-animation").then((mod) => mod.LottieAnimation),
{
ssr: false,
loading: () => (
<div className="flex h-full w-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-500" />
</div>
),
}
);
3. Store Animation JSON Files
Place Lottie JSON files in src/animations/ so the @/ alias works (since @/ maps to ./src/). Do NOT put them under public/ if you plan to import them — the @/public/ path will fail because @/ resolves to ./src/, not the project root.
src/
animations/
checkmark.json
loading.json
success.json
4. Use in Page
Important: If you pass event handler props (like onComplete, onLoopComplete), the parent must be a Client Component. Server Components cannot pass functions as props to Client Components.
// app/page.tsx
"use client";
import { DynamicLottie } from "@/components/lottie/dynamic-lottie";
import checkmarkAnimation from "@/animations/checkmark.json";
export default function Page() {
return (
<div className="h-64 w-64">
<DynamicLottie
animationData={checkmarkAnimation}
loop={false}
=> console.log("Animation complete!")}
/>
</div>
);
}
If you don't need event handlers, the page can remain a Server Component:
// app/page.tsx (Server Component - no event handlers)
import { DynamicLottie } from "@/components/lottie/dynamic-lottie";
import checkmarkAnimation from "@/animations/checkmark.json";
export default function Page() {
return (
<div className="h-64 w-64">
<DynamicLottie animationData={checkmarkAnimation} loop={false} />
</div>
);
}
Core Props
| Prop | Type | Default | Description |
|---|---|---|---|
animationData |
object |
required | The Lottie JSON data |
loop |
boolean | number |
true |
Loop infinitely or N times |
autoplay |
boolean |
true |
Start playing immediately |
speed |
number |
1 |
Playback speed (1 = normal) |
initialSegment |
[number, number] |
- | Start/end frames |
style |
CSSProperties |
- | Container styles |
className |
string |
- | Container class |
Playback Control Methods
Access via lottieRef:
"use client";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
import { useRef } from "react";
import animationData from "./animation.json";
export function ControlledAnimation() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
return (
<div>
<Lottie
lottieRef={lottieRef}
animationData={animationData}
autoplay={false}
/>
<div className="flex gap-2 mt-4">
<button => lottieRef.current?.play()}>
Play
</button>
<button => lottieRef.current?.pause()}>
Pause
</button>
<button => lottieRef.current?.stop()}>
Stop
</button>
<button => lottieRef.current?.setSpeed(2)}>
2x Speed
</button>
<button => lottieRef.current?.setDirection(-1)}>
Reverse
</button>
<button => lottieRef.current?.goToAndPlay(0, true)}>
Restart
</button>
</div>
</div>
);
}
All Methods
| Method | Parameters | Description |
|---|---|---|
play() |
- | Start playback |
pause() |
- | Pause at current frame |
stop() |
- | Stop and reset to frame 0 |
setSpeed(speed) |
number |
Set playback speed |
setDirection(dir) |
1 | -1 |
Forward or reverse |
goToAndPlay(value, isFrame?) |
number, boolean |
Jump and play |
goToAndStop(value, isFrame?) |
number, boolean |
Jump and stop |
playSegments(segments, force?) |
[number, number][], boolean |
Play specific segments |
getDuration(inFrames?) |
boolean |
Get total duration |
destroy() |
- | Cleanup |
Event Callbacks
<Lottie
animationData={animationData}
=> console.log("Animation finished")}
=> console.log("Loop completed")}
=> console.log("Frame:", e.currentTime)}
=> console.log("Segment started")}
=> console.log("Config ready")}
=> console.log("Data loaded")}
=> console.log("DOM ready")}
=> console.log("Destroyed")}
/>
Hover Trigger Animation
"use client";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
import { useRef } from "react";
import iconAnimation from "./icon.json";
export function HoverIcon() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
return (
<div
className="h-12 w-12 cursor-pointer"
=> lottieRef.current?.play()}
=> lottieRef.current?.stop()}
>
<Lottie
lottieRef={lottieRef}
animationData={iconAnimation}
autoplay={false}
loop={false}
/>
</div>
);
}
Click Toggle Animation
"use client";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
import { useRef, useState } from "react";
import toggleAnimation from "./toggle.json";
export function ClickToggle() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const [isActive, setIsActive] = useState(false);
const handleClick = () => {
if (isActive) {
lottieRef.current?.setDirection(-1);
} else {
lottieRef.current?.setDirection(1);
}
lottieRef.current?.play();
setIsActive(!isActive);
};
return (
<button className="h-16 w-16">
<Lottie
lottieRef={lottieRef}
animationData={toggleAnimation}
autoplay={false}
loop={false}
/>
</button>
);
}
Scroll-Triggered Animation
"use client";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
import { useRef, useEffect } from "react";
import scrollAnimation from "./scroll-reveal.json";
export function ScrollReveal() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
lottieRef.current?.play();
} else {
lottieRef.current?.stop();
}
});
},
{ threshold: 0.5 }
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={containerRef} className="h-64 w-64">
<Lottie
lottieRef={lottieRef}
animationData={scrollAnimation}
autoplay={false}
loop={false}
/>
</div>
);
}
Scroll Progress Sync
"use client";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
import { useRef, useEffect, useState } from "react";
import progressAnimation from "./progress.json";
export function ScrollProgress() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [totalFrames, setTotalFrames] = useState(0);
useEffect(() => {
const duration = lottieRef.current?.getDuration(true);
if (duration) setTotalFrames(duration);
}, []);
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current || !lottieRef.current || !totalFrames) return;
const rect = containerRef.current.getBoundingClientRect();
const scrollProgress = Math.max(
0,
Math.min(1, 1 - rect.top / window.innerHeight)
);
const frame = Math.floor(scrollProgress * totalFrames);
lottieRef.current.goToAndStop(frame, true);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [totalFrames]);
return (
<div ref={containerRef} className="sticky top-0 h-screen">
<Lottie
lottieRef={lottieRef}
animationData={progressAnimation}
autoplay={false}
loop={false}
/>
</div>
);
}
Accessibility: Reduced Motion
"use client";
import Lottie from "lottie-react";
import { useEffect, useState } from "react";
import animationData from "./animation.json";
export function AccessibleAnimation() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReducedMotion(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => {
setPrefersReducedMotion(e.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, []);
if (prefersReducedMotion) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-100">
<span className="text-2xl">✓</span>
</div>
);
}
return <Lottie animationData={animationData} />;
}
Loading State Component
"use client";
import dynamic from "next/dynamic";
import loadingAnimation from "@/animations/loading.json";
const Lottie = dynamic(() => import("lottie-react"), { ssr: false });
type LoadingSpinnerProps = {
size?: number;
className?: string;
};
export function LoadingSpinner({ size = 48, className }: LoadingSpinnerProps) {
return (
<div
className={className}
style={{ width: size, height: size }}
role="status"
aria-label="Loading"
>
<Lottie
animationData={loadingAnimation}
loop
autoplay
/>
</div>
);
}
Success/Error Feedback
"use client";
import dynamic from "next/dynamic";
import successAnimation from "@/animations/success.json";
import errorAnimation from "@/animations/error.json";
const Lottie = dynamic(() => import("lottie-react"), { ssr: false });
type FeedbackProps = {
type: "success" | "error";
onComplete?: () => void;
};
export function Feedback({ type, onComplete }: FeedbackProps) {
const animationData = type === "success" ? successAnimation : errorAnimation;
return (
<div className="h-24 w-24">
<Lottie
animationData={animationData}
loop={false}
/>
</div>
);
}
Performance Best Practices
1. Lazy Load Animations
import dynamic from "next/dynamic";
const HeroAnimation = dynamic(
() => import("@/components/lottie/hero-animation"),
{
ssr: false,
loading: () => <div className="h-96 w-full animate-pulse bg-gray-200" />,
}
);
2. Optimize JSON Files
- Use LottieFiles Optimizer
- Remove unused layers and assets
- Reduce keyframes where possible
- Target < 50KB for hero animations, < 10KB for icons
3. Use Segments for Long Animations
// Only play frames 0-60 instead of the whole animation
<Lottie
animationData={animationData}
initialSegment={[0, 60]}
/>
4. Destroy on Unmount
The component handles this automatically, but for manual control:
useEffect(() => {
return () => {
lottieRef.current?.destroy();
};
}, []);
5. Reduce Render Quality on Low-End Devices
<Lottie
animationData={animationData}
rendererSettings={{
preserveAspectRatio: "xMidYMid slice",
progressiveLoad: true,
}}
/>
File Structure
src/
components/
lottie/
lottie-animation.tsx # Base component
dynamic-lottie.tsx # SSR-safe wrapper
loading-spinner.tsx # Loading state
hover-icon.tsx # Hover trigger
scroll-reveal.tsx # Intersection observer
animations/
loading.json # Loading spinner
success.json # Success checkmark
error.json # Error X
hero.json # Hero section
Where to Get Animations
| Source | URL | Notes |
|---|---|---|
| LottieFiles | https://lottiefiles.com/free-animations | Largest free library |
| IconScout | https://iconscout.com/lottie-animations | Curated collections |
| Lordicon | https://lordicon.com | Animated icons |
| Creattie | https://creattie.com | Free icon packs |
| Motion Elements | https://www.motionelements.com/lottie | Premium options |
Importing JSON Files
Option 1: Import directly (recommended)
Place JSON files in src/animations/ and use the @/ alias:
import animationData from "@/animations/success.json";
JSON imports work by default in Next.js — no additional config needed.
Important: Do NOT use @/public/animations/... for imports. The @/ alias maps to ./src/, so @/public/ resolves to ./src/public/ which does not exist. Either place files in src/animations/ or use runtime fetching from public/.
Option 2: Fetch at runtime (from public/)
If you prefer to serve JSON from the public/ directory (for CDN caching), fetch at runtime:
const [animationData, setAnimationData] = useState<object | null>(null);
useEffect(() => {
fetch("/animations/success.json")
.then((res) => res.json())
.then(setAnimationData);
}, []);
Vercel Deployment
- JSON files: Place in
src/animations/for direct imports. If you need CDN-served static files, place inpublic/animations/and fetch at runtime. - Bundle size: lottie-react is ~45KB minified
- Edge compatibility: Works with Edge Runtime (client-side only)
- CDN caching: Files in
public/are cached at edge automatically
Common Issues
Server Components and Event Handlers
You cannot pass function props (like onComplete, onLoopComplete) from a Server Component to a Client Component. If your page needs event handlers, mark it as "use client":
// WRONG - Server Component passing a function prop
import { DynamicLottie } from "@/components/lottie/dynamic-lottie";
export default function Page() {
return <DynamicLottie => {}} />; // Error!
}
// CORRECT - Client Component can pass function props
"use client";
import { DynamicLottie } from "@/components/lottie/dynamic-lottie";
export default function Page() {
return <DynamicLottie => {}} />; // Works
}
Biome/ESLint Import Order
Type imports should come after React imports but before default imports:
// Correct order for Biome compatibility
import { useCallback, useEffect, useRef, useState } from "react";
import Lottie from "lottie-react";
import type { LottieRefCurrentProps } from "lottie-react";
import type { BMCompleteEvent, BMEnterFrameEvent } from "lottie-web";
Accessibility: Label vs Span for Button Groups
Use <span> instead of <label> for button group headings. Labels must be associated with form controls:
// WRONG - causes accessibility lint errors
<label>Speed: {speed}x</label>
<div className="flex gap-2">
<button>0.5x</button>
<button>1x</button>
</div>
// CORRECT - use span for button group headings
<span className="block text-sm font-medium">Speed: {speed}x</span>
<div className="flex gap-2">
<button>0.5x</button>
<button>1x</button>
</div>
// CORRECT - label with associated input
<label htmlFor="frame-slider">Frame Seek</label>
<input id="frame-slider" type="range" />
Importing JSON from Components
Always use the @/ alias pointing to src/animations/:
// From any file under src/
import checkmarkAnimation from "@/animations/checkmark.json";
Event Handler Types
Import event types from lottie-web for proper typing:
import type {
BMCompleteEvent,
BMCompleteLoopEvent,
BMEnterFrameEvent,
} from "lottie-web";
const handleEnterFrame = (event: BMEnterFrameEvent) => {
console.log("Frame:", event.currentTime);
};
"document is not defined"
Always use dynamic import with ssr: false:
const Lottie = dynamic(() => import("lottie-react"), { ssr: false });
Animation not playing
Check autoplay={true} and ensure the component is mounted:
useEffect(() => {
lottieRef.current?.play();
}, []);
Animation flickering
Set explicit dimensions on container:
<div className="h-64 w-64">
<Lottie animationData={data} />
</div>
Type errors with JSON import
Create a type declaration file:
// types/lottie.d.ts
declare module "*.json" {
const value: object;
export default value;
}