DEV Community

Fazal Shah
Fazal Shah

Posted on

Lottie Animations in Remix: A Complete Guide

Remix's SSR-first model means Lottie needs careful handling — it relies on browser APIs that don't exist during server rendering. This guide covers every pattern for safe Lottie integration in Remix, from basic client components to route-level deferred loading.


The Problem with Lottie + SSR

Lottie uses document, window, and requestAnimationFrame. Remix renders on the server by default, so naive Lottie usage throws:

ReferenceError: document is not defined
Enter fullscreen mode Exit fullscreen mode

The fix: gate Lottie initialization to the client only.


Before You Start

Open animation files in IconKing before integrating:

  • Preview colors, timing, and layers
  • Convert .json.lottie for 75% smaller files
  • Verify the animation renders correctly

Installation

npm install lottie-react
# or
npm install @lottiefiles/dotlottie-react
Enter fullscreen mode Exit fullscreen mode

Option 1: ClientOnly Component (Recommended)

Remix doesn't ship a ClientOnly component, but it's simple to build:

// app/components/ClientOnly.tsx
import { useState, useEffect, type ReactNode } from 'react';

export function ClientOnly({ children }: { children: ReactNode }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  return mounted ? <>{children}</> : null;
}
Enter fullscreen mode Exit fullscreen mode
// app/routes/_index.tsx
import { ClientOnly } from '~/components/ClientOnly';
import Lottie from 'lottie-react';
import heroAnim from '~/animations/hero.json';

export default function Index() {
  return (
    <main>
      <h1>Welcome</h1>
      <ClientOnly>
        <div style={{ width: 400, height: 400 }}>
          <Lottie animationData={heroAnim} loop autoplay />
        </div>
      </ClientOnly>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Option 2: Dynamic Import with useEffect

For large animations or when you want to avoid bundling lottie-react upfront:

// app/components/LazyLottie.tsx
import { useState, useEffect, useRef } from 'react';

interface LazyLottieProps {
  src: string;
  width?: number;
  height?: number;
  loop?: boolean;
}

export function LazyLottie({ src, width = 300, height = 300, loop = true }: LazyLottieProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    let anim: any;

    import('lottie-web').then(({ default: lottie }) => {
      anim = lottie.loadAnimation({
        container: containerRef.current!,
        renderer: 'svg',
        loop,
        autoplay: true,
        path: src,
      });
    });

    return () => anim?.destroy();
  }, [src, loop]);

  return <div ref={containerRef} style={{ width, height }} />;
}
Enter fullscreen mode Exit fullscreen mode
// In a route
import { LazyLottie } from '~/components/LazyLottie';

export default function About() {
  return (
    <section>
      <h2>About Us</h2>
      {/* src is a URL to public/animations/about.json */}
      <LazyLottie src="/animations/about.json" width={300} height={300} />
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Place animation files in public/ — Remix serves public/ statically at the root.


Option 3: dotLottie (Smaller Files)

// app/components/DotLottiePlayer.tsx
import { ClientOnly } from '~/components/ClientOnly';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';

export function DotLottiePlayer({ src, width = 300, height = 300 }: {
  src: string;
  width?: number;
  height?: number;
}) {
  return (
    <ClientOnly>
      <DotLottieReact
        src={src}
        loop
        autoplay
        style={{ width, height }}
      />
    </ClientOnly>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Usage in route
<DotLottiePlayer src="/animations/hero.lottie" width={400} height={400} />
Enter fullscreen mode Exit fullscreen mode

Remix loader + Deferred Animation Data

Use Remix's defer to preload animation JSON without blocking the page render:

// app/routes/product.$id.tsx
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
import { ClientOnly } from '~/components/ClientOnly';
import Lottie from 'lottie-react';

export async function loader({ params }: { params: { id: string } }) {
  // Fetch animation in parallel with other data
  const animDataPromise = fetch(`https://your-cdn.com/animations/${params.id}.json`)
    .then(r => r.json());

  return defer({ animData: animDataPromise });
}

export default function Product() {
  const { animData } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Product</h1>
      <Suspense fallback={<div style={{ width: 300, height: 300, background: '#f0f0f0' }} />}>
        <Await resolve={animData}>
          {(data) => (
            <ClientOnly>
              <div style={{ width: 300, height: 300 }}>
                <Lottie animationData={data} loop autoplay />
              </div>
            </ClientOnly>
          )}
        </Await>
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useFetcher for On-Demand Animation Loading

Load animation data only when needed (e.g., on hover or button click):

import { useFetcher } from '@remix-run/react';
import { useState, useEffect, useRef } from 'react';

export function OnDemandAnimation() {
  const fetcher = useFetcher<{ animData: object }>();
  const containerRef = useRef<HTMLDivElement>(null);
  const [loaded, setLoaded] = useState(false);

  function handleHover() {
    if (!loaded) {
      fetcher.load('/api/animation-data?name=confetti');
    }
  }

  useEffect(() => {
    if (fetcher.data?.animData && containerRef.current && !loaded) {
      import('lottie-web').then(({ default: lottie }) => {
        lottie.loadAnimation({
          container: containerRef.current!,
          renderer: 'svg',
          loop: false,
          autoplay: true,
          animationData: fetcher.data!.animData,
        });
        setLoaded(true);
      });
    }
  }, [fetcher.data]);

  return (
    <button onMouseEnter={handleHover} style={{ border: 'none', background: 'none' }}>
      <div ref={containerRef} style={{ width: 48, height: 48 }} />
      Submit
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

IntersectionObserver for Scroll Animations

import { useRef, useEffect } from 'react';
import Lottie from 'lottie-react';
import type { LottieRefCurrentProps } from 'lottie-react';

export function ScrollAnimation({ animationData }: { animationData: object }) {
  const lottieRef = useRef<LottieRefCurrentProps>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        entry.isIntersecting ? lottieRef.current?.play() : lottieRef.current?.pause();
      },
      { threshold: 0.2 }
    );

    if (containerRef.current) observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={containerRef}>
      <ClientOnly>
        <Lottie
          lottieRef={lottieRef}
          animationData={animationData}
          loop={false}
          autoplay={false}
        />
      </ClientOnly>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

prefers-reduced-motion

import { useEffect, useState } from 'react';

function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return reduced;
}

export function AccessibleLottie({ animationData }: { animationData: object }) {
  const reducedMotion = useReducedMotion();

  return (
    <ClientOnly>
      <div style={{ width: 200, height: 200 }}>
        <Lottie
          animationData={animationData}
          loop={!reducedMotion}
          autoplay={!reducedMotion}
        />
      </div>
    </ClientOnly>
  );
}
Enter fullscreen mode Exit fullscreen mode

File Organization in Remix

app/
  animations/        ← small JSON files (bundled with JS — only for < 10KB)
    icon.json
  components/
    ClientOnly.tsx
    LazyLottie.tsx
    DotLottiePlayer.tsx
public/
  animations/        ← large files served statically
    hero.json
    hero.lottie
    product.lottie
Enter fullscreen mode Exit fullscreen mode

Rule: files under 10KB can be imported directly. Larger files go in public/ and are fetched at runtime.


Performance Checklist for Remix

Check Solution
SSR window errors ClientOnly component or useEffect guard
Large files in JS bundle Store in public/, reference by URL
Unused animations loading defer in loader + Suspense boundary
Off-screen animations running IntersectionObserver hook
Memory leaks across navigations Always return cleanup in useEffect
Large .json files Convert to .lottie at IconKing

Summary

  1. Build a ClientOnly component — Remix has no built-in equivalent
  2. Use dynamic import('lottie-web') inside useEffect to avoid SSR issues and reduce bundle size
  3. Put large animations in public/animations/ and reference by URL string
  4. Use Remix's defer for above-fold animations that benefit from preloading
  5. Convert to .lottie at IconKing — 75% smaller files improve Lighthouse and LCP
  6. Always clean up with anim.destroy() in the useEffect return function

Top comments (0)