Limited offer — lifetime access:$99$79
Articlenextjsanimated iconslottierivetutorial

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

bash
copy
npm 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:

tsx
copy
// 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

tsx
copy
// 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:

tsx
copy
"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:

tsx
copy
"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

bash
copy
npm install @rive-app/react-canvas
# or
yarn add @rive-app/react-canvas

Create a client component

tsx
copy
// 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

tsx
copy
// 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:

tsx
copy
"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?

LottieRive
File format.json (text).riv (binary)
Typical file size20–80KB5–20KB
SSR supportNo (needs ssr: false)No (needs ssr: false)
Interactive triggersLimitedFull state machines
React ecosystemMature (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:

Browse the full animated icon library to find icons that fit your UI.