renderer selection. No additional framework-specific packages are required.
Step 2: Define the Component Architecture
Create src/lib/components/MotionPlayer.svelte. The component accepts configuration via typed props, binds to a container element, and manages the animation instance internally.
// src/lib/components/MotionPlayer.svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import lottie from 'lottie-web';
import type { AnimationItem } from 'lottie-web';
export interface MotionConfig {
path: string;
loop: boolean;
autoplay: boolean;
renderer: 'svg' | 'canvas';
width: string;
height: string;
}
export let config: MotionConfig = {
path: '/assets/motion/default.json',
loop: true,
autoplay: true,
renderer: 'svg',
width: '240px',
height: '240px'
};
let containerRef: HTMLDivElement;
let playerInstance: AnimationItem | null = null;
let isReady = false;
let loadError: string | null = null;
onMount(() => {
try {
playerInstance = lottie.loadAnimation({
container: containerRef,
renderer: config.renderer,
loop: config.loop,
autoplay: config.autoplay,
path: config.path
});
playerInstance.addEventListener('DOMLoaded', () => {
isReady = true;
});
} catch (err) {
loadError = err instanceof Error ? err.message : 'Failed to initialize motion player';
}
});
onDestroy(() => {
if (playerInstance) {
playerInstance.destroy();
playerInstance = null;
}
});
export function togglePlayback() {
if (!playerInstance) return;
playerInstance.isPlaying ? playerInstance.pause() : playerInstance.play();
}
export function seekToFrame(frame: number) {
playerInstance?.goToAndStop(frame, true);
}
</script>
<div
bind:this={containerRef}
class="motion-container"
style="width: {config.width}; height: {config.height};"
aria-label="Vector animation"
>
{#if loadError}
<span class="error-state">{loadError}</span>
{:else if !isReady}
<span class="loading-state">Initializing...</span>
{/if}
</div>
<style>
.motion-container {
position: relative;
overflow: hidden;
background: transparent;
}
.error-state, .loading-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
color: #6b7280;
}
</style>
Step 3: Parent Component Integration
Parent components interact with the player through exported methods and reactive state. This avoids direct DOM manipulation and keeps control logic decoupled.
<script lang="ts">
import MotionPlayer from '$lib/components/MotionPlayer.svelte';
import type { MotionConfig } from '$lib/components/MotionPlayer.svelte';
let playerRef: { togglePlayback: () => void; seekToFrame: (f: number) => void };
const motionSettings: MotionConfig = {
path: '/assets/motion/checkout-success.json',
loop: false,
autoplay: true,
renderer: 'svg',
width: '320px',
height: '320px'
};
</script>
<MotionPlayer
bind:this={playerRef}
config={motionSettings}
/>
<div class="controls">
<button on:click={() => playerRef?.togglePlayback()}>
Toggle Playback
</button>
<button on:click={() => playerRef?.seekToFrame(0)}>
Reset to Start
</button>
</div>
Architecture Decisions & Rationale
- TypeScript Interfaces: Enforces contract consistency between parent and child components. Prevents runtime type mismatches when passing configuration objects.
onMount Guarantee: Svelte compiles onMount to run exclusively in the browser. This eliminates SSR hydration crashes without requiring manual typeof window !== 'undefined' checks.
- Explicit
onDestroy Teardown: Calls .destroy() on the animation instance, which cancels internal requestAnimationFrame loops, removes event listeners, and clears DOM references. This prevents memory accumulation in route-heavy applications.
- Method Export over Binding: Exposing
togglePlayback and seekToFrame as callable functions creates a clean imperative API. It avoids the complexity of two-way binding for animation state, which can cause race conditions during rapid prop updates.
- Renderer Selection: Defaults to
svg for crisp scaling and CSS compatibility. canvas is available for high-frequency animations but requires explicit trade-off consideration (see Decision Matrix).
Pitfall Guide
1. Missing Teardown on Unmount
Explanation: Failing to call .destroy() leaves requestAnimationFrame loops active. The browser continues allocating memory for frame calculations even after the component is removed from the DOM.
Fix: Always pair onMount initialization with onDestroy cleanup. Store the instance in a module-level variable and invoke .destroy() conditionally.
2. SSR Hydration Mismatch
Explanation: Attempting to run lottie.loadAnimation() during server rendering triggers ReferenceError: window is not defined because the DOM API does not exist in Node.js.
Fix: Rely on Svelte's onMount hook. It is compiled to execute only in the browser environment. Avoid manual browser checks unless building a universal library.
3. Uncontrolled Autoplay in Virtualized Lists
Explanation: Setting autoplay: true on every item in a scrollable list causes simultaneous frame loops, spiking CPU usage and causing jank.
Fix: Default to autoplay: false. Use an IntersectionObserver to trigger .play() only when the container enters the viewport. Pause or destroy instances that leave the viewport.
4. Renderer Choice Without Performance Context
Explanation: svg renderer scales cleanly but creates heavy DOM trees for complex animations. canvas is faster for high-frame-count sequences but loses CSS styling capabilities and accessibility tree integration.
Fix: Profile animation complexity. Use svg for UI feedback (toasts, buttons, icons). Use canvas for background loops or high-density motion graphics.
5. Prop Mutation Race Conditions
Explanation: Updating config.path or config.loop after mount does not automatically reinitialize the animation. lottie-web does not observe prop changes.
Fix: Treat configuration as immutable after mount. If dynamic path switching is required, destroy the existing instance and call loadAnimation again with the new configuration.
6. Silent Asset Loading Failures
Explanation: Network timeouts or missing JSON files fail silently. The container remains empty, and users receive no feedback.
Fix: Wrap initialization in a try/catch block. Attach a data_failed event listener to the animation instance. Render a fallback UI or retry mechanism when loading fails.
7. Ignoring Bundle Size Impact
Explanation: Importing the full lottie-web package adds ~150KB minified. Many projects only need SVG rendering and basic playback controls.
Fix: Use tree-shaking aware imports. If canvas rendering is unnecessary, consider lightweight alternatives or lazy-load the module only when the component mounts.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| UI Feedback (buttons, toasts, success states) | svg renderer + autoplay: true | Crisp scaling, CSS compatibility, accessible DOM tree | Low |
| Background loops / high-density motion | canvas renderer + autoplay: false | Lower DOM overhead, better frame rates for complex paths | Medium |
| Scrollable lists / virtualized grids | IntersectionObserver + manual play/pause | Prevents simultaneous frame loops, reduces CPU spike | Low |
| Dynamic path switching | Destroy + reinitialize on prop change | lottie-web does not react to prop updates natively | Medium |
| Strict bundle size limits | Dynamic import('lottie-web') in onMount | Defers parsing until client-side mount, reduces initial payload | Low |
Configuration Template
Copy this template into your project. It includes TypeScript typing, error handling, loading states, and a clean imperative API for parent components.
<!-- src/lib/components/MotionPlayer.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import lottie from 'lottie-web';
import type { AnimationItem } from 'lottie-web';
export interface MotionConfig {
path: string;
loop: boolean;
autoplay: boolean;
renderer: 'svg' | 'canvas';
width: string;
height: string;
}
export let config: MotionConfig = {
path: '/assets/motion/default.json',
loop: true,
autoplay: true,
renderer: 'svg',
width: '240px',
height: '240px'
};
let containerRef: HTMLDivElement;
let playerInstance: AnimationItem | null = null;
let isReady = false;
let loadError: string | null = null;
onMount(() => {
try {
playerInstance = lottie.loadAnimation({
container: containerRef,
renderer: config.renderer,
loop: config.loop,
autoplay: config.autoplay,
path: config.path
});
playerInstance.addEventListener('DOMLoaded', () => {
isReady = true;
});
playerInstance.addEventListener('data_failed', () => {
loadError = 'Animation asset failed to load';
});
} catch (err) {
loadError = err instanceof Error ? err.message : 'Initialization failed';
}
});
onDestroy(() => {
if (playerInstance) {
playerInstance.destroy();
playerInstance = null;
}
});
export function togglePlayback() {
if (!playerInstance) return;
playerInstance.isPlaying ? playerInstance.pause() : playerInstance.play();
}
export function seekToFrame(frame: number) {
playerInstance?.goToAndStop(frame, true);
}
</script>
<div
bind:this={containerRef}
class="motion-container"
style="width: {config.width}; height: {config.height};"
aria-label="Vector animation"
>
{#if loadError}
<span class="error-state">{loadError}</span>
{:else if !isReady}
<span class="loading-state">Initializing...</span>
{/if}
</div>
<style>
.motion-container {
position: relative;
overflow: hidden;
background: transparent;
}
.error-state, .loading-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
color: #6b7280;
}
</style>
Quick Start Guide
- Install the runtime: Run
npm install lottie-web in your project root.
- Place your asset: Drop a
.json or .lottie file into static/assets/motion/ (SvelteKit) or public/assets/motion/ (Vite).
- Import the component: Add
<MotionPlayer config={yourConfig} /> to any route or layout.
- Control playback: Bind to the component instance and call
togglePlayback() or seekToFrame() from parent event handlers.
- Verify SSR safety: Run
npm run build and confirm no window is not defined errors appear during server compilation. The onMount guard handles this automatically.