How to Add Animated Icons to a Next.js App (Lottie & Rive)
Step-by-step guide to integrating Lottie and Rive animated icons into a Next.js 14/15 App Router project. Covers SSR gotchas, dynamic imports, and performance best practices.
Animated icons add polish to any UI — but integrating them into a Next.js app has a few gotchas that aren't obvious from the library docs. This guide covers both Lottie and Rive, with working code examples for Next.js 14 and 15 (App Router).
You can get the icon files (.lottie, .riv) from Unicorn Icons — 100 are free, no account needed.
The SSR problem
Both lottie-web and the Rive runtime rely on the browser's canvas and animation APIs. They cannot run during server-side rendering. If you try to import them directly, you'll hit:
ReferenceError: document is not defined
The fix in Next.js is dynamic() with { ssr: false }. We'll use that throughout this guide.
Option 1: Lottie in Next.js
Install
bashcopynpm install lottie-react # or yarn add lottie-react
Create a client component
Wrap the Lottie player in a client component so it can be lazy-loaded:
tsxcopy// components/LottieIcon.tsx "use client"; import Lottie from "lottie-react"; interface LottieIconProps { animationData: object; className?: string; loop?: boolean; autoplay?: boolean; } export default function LottieIcon({ animationData, className, loop = true, autoplay = true, }: LottieIconProps) { return ( <Lottie animationData={animationData} className={className} loop={loop} autoplay={autoplay} /> ); }
Use it in a Server Component with dynamic import
tsxcopy// app/page.tsx (Server Component) import dynamic from "next/dynamic"; import arrowAnimation from "@/public/icons/arrow.json"; const LottieIcon = dynamic(() => import("@/components/LottieIcon"), { ssr: false, loading: () => <div className="w-8 h-8" />, // placeholder to prevent layout shift }); export default function Page() { return ( <div> <LottieIcon animationData={arrowAnimation} className="w-8 h-8" /> </div> ); }
Play on hover
A common pattern is to trigger the animation on mouse hover:
tsxcopy"use client"; import { useRef } from "react"; import Lottie, { LottieRefCurrentProps } from "lottie-react"; import arrowAnimation from "@/public/icons/arrow.json"; export default function HoverIcon() { const lottieRef = useRef<LottieRefCurrentProps>(null); return ( <button onMouseEnter={() => lottieRef.current?.play()} onMouseLeave={() => { lottieRef.current?.stop(); }} > <Lottie lottieRef={lottieRef} animationData={arrowAnimation} loop={false} autoplay={false} className="w-8 h-8" /> </button> ); }
Performance tip: bundle size
lottie-react pulls in lottie-web which is ~250KB. If you only need a few icons, consider loading the JSON data on-demand:
tsxcopy"use client"; import { useState, useEffect } from "react"; import Lottie from "lottie-react"; export default function LazyLottieIcon({ src }: { src: string }) { const [animationData, setAnimationData] = useState(null); useEffect(() => { fetch(src) .then((r) => r.json()) .then(setAnimationData); }, [src]); if (!animationData) return <div className="w-8 h-8" />; return <Lottie animationData={animationData} className="w-8 h-8" />; }
This defers the JSON fetch until the component mounts, keeping your initial bundle lean.
Option 2: Rive in Next.js
Rive animations support interactive state machines — hover states, click triggers, and scroll-driven animations. The .riv format is significantly smaller than Lottie JSON files.
Install
bashcopynpm install @rive-app/react-canvas # or yarn add @rive-app/react-canvas
Create a client component
tsxcopy// components/RiveIcon.tsx "use client"; import { useRive, Layout, Fit, Alignment } from "@rive-app/react-canvas"; interface RiveIconProps { src: string; stateMachineName?: string; className?: string; } export default function RiveIcon({ src, stateMachineName, className }: RiveIconProps) { const { RiveComponent } = useRive({ src, stateMachines: stateMachineName ? [stateMachineName] : undefined, autoplay: true, layout: new Layout({ fit: Fit.Contain, alignment: Alignment.Center, }), }); return <RiveComponent className={className} />; }
Use with dynamic import
tsxcopy// app/page.tsx import dynamic from "next/dynamic"; const RiveIcon = dynamic(() => import("@/components/RiveIcon"), { ssr: false, loading: () => <div className="w-8 h-8" />, }); export default function Page() { return ( <RiveIcon src="/icons/arrow.riv" stateMachineName="State Machine 1" className="w-8 h-8" /> ); }
Hover interaction with state machine
Rive's power is interactive state machines. Here's a hover trigger:
tsxcopy"use client"; import { useRive, useStateMachineInput } from "@rive-app/react-canvas"; export default function HoverRiveIcon({ src }: { src: string }) { const { RiveComponent, rive } = useRive({ src, stateMachines: ["State Machine 1"], autoplay: true, }); const hoverInput = useStateMachineInput(rive, "State Machine 1", "Hover"); return ( <button onMouseEnter={() => { if (hoverInput) hoverInput.value = true; }} onMouseLeave={() => { if (hoverInput) hoverInput.value = false; }} className="w-8 h-8" > <RiveComponent /> </button> ); }
The "Hover" string matches the boolean input name in the Rive state machine. All Unicorn Icons Rive files ship with a Hover boolean input by default.
Lottie vs Rive — which to use in Next.js?
| Lottie | Rive | |
|---|---|---|
| File format | .json (text) | .riv (binary) |
| Typical file size | 20–80KB | 5–20KB |
| SSR support | No (needs ssr: false) | No (needs ssr: false) |
| Interactive triggers | Limited | Full state machines |
| React ecosystem | Mature (lottie-react) | Growing (@rive-app/react-canvas) |
For static looping animations, Lottie is the simpler choice. For hover/click/scroll interactions, Rive gives you more control with less JavaScript.
Both formats are included in every Unicorn Icons download — you can start with one and switch without re-sourcing the icon.
Common mistakes
1. Importing the animation JSON at the top level in a Server Component
Next.js will try to serialize the JSON as server data. Keep animation imports inside client components.
2. Missing loading prop on dynamic import
Without a placeholder, the icon area collapses to zero height while loading, causing cumulative layout shift (CLS). Always provide a loading skeleton with the same dimensions.
3. Not setting explicit width/height
Both lottie-react and the Rive canvas need explicit dimensions to render correctly. Use Tailwind w-8 h-8 or an inline style — don't rely on the parent to size the canvas.
Integration guides
For platform-specific implementations beyond Next.js:
- Lottie in React — general React integration
- Rive in React — general React integration
- Lottie in Vue
- Lottie on iOS
- Lottie on Android
Browse the full animated icon library to find icons that fit your UI.