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
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→.lottiefor 75% smaller files - Verify the animation renders correctly
Installation
npm install lottie-react
# or
npm install @lottiefiles/dotlottie-react
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;
}
// 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>
);
}
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 }} />;
}
// 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>
);
}
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>
);
}
// Usage in route
<DotLottiePlayer src="/animations/hero.lottie" width={400} height={400} />
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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
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
- Build a
ClientOnlycomponent — Remix has no built-in equivalent - Use dynamic
import('lottie-web')insideuseEffectto avoid SSR issues and reduce bundle size - Put large animations in
public/animations/and reference by URL string - Use Remix's
deferfor above-fold animations that benefit from preloading - Convert to
.lottieat IconKing — 75% smaller files improve Lighthouse and LCP - Always clean up with
anim.destroy()in theuseEffectreturn function
Top comments (0)