Earlier this year I built a demo for Figma Config: an interactive character showcase — two Arcane characters, Jinx and Vi, with animated outlines, ambient backgrounds, hover states, and indicator buttons that change animation based on which character is active. The whole thing was designed in Figma using Smart Animate, exported as dotLottie files, and wired up in React with Framer Motion handling the interaction layer.
The Figma source file is available on the community — components, variants, Smart Animate prototyping, everything:
Design meets Code — Figma Config 2025
And the talk itself — recorded at Figma Config 2025 in San Francisco:
That project crystallised something I’d been thinking about for a while: the Figma-to-code handoff problem for animation isn’t really one problem. It’s two. And mixing up the tools for each is where most implementations go wrong.
The two problems
Problem 1: the Figma animation. Complex, multi-layer motion you authored in a design tool. Character outline draws in on hover. Ambient background loops. These have specific timing, specific curves, specific frame sequences that you tuned in Figma. You can’t recreate them by hand in CSS or Framer Motion without losing that fidelity.
Problem 2: the interaction logic. Which state is active. What triggers the animation. How the UI responds to hover, click, keyboard. This is development territory — state machines, event handlers, transitions between views.
The mistake is trying to solve both with the same tool. Using Framer Motion for everything means recreating the Figma animation from scratch in code and losing fidelity in the process. Using dotLottie for everything means your interaction logic is buried in a JSON state machine that’s hard to debug and impossible to wire to React state cleanly.
The split that works: dotLottie for what you built in Figma, Framer Motion for what you wire up in React.
The Figma → dotLottie pipeline
Figma’s Smart Animate prototyping can be exported to dotLottie via the LottieFiles Figma plugin. The export captures the component variants and the transitions between them — including easing, duration, and properties like opacity, scale, and position that Smart Animate interpolates.
What you get is a .lottie file with named segments. Each segment maps to a transition in the Figma prototype. In the Config demo, each character had four .lottie files:
- Outline — a single-play animation that draws the character’s silhouette on hover
- Background — an ambient loop that runs continuously
- Indicator idle — the default state of the selector button
- Indicator active — the state when that character is selected
The indicator animations are the interesting one. In Figma, the idle and active states were two component variants connected with Smart Animate. The export gave two separate .lottie files — one per state. That’s the pattern: one file per meaningful animation state, not one file with everything.
Setting up @lottiefiles/dotlottie-web
The player that actually handles .lottie files in the browser is @lottiefiles/dotlottie-web — not lottie-web, not @dotlottie/react-player. Those are different packages.
npm install @lottiefiles/dotlottie-web
It uses a WebAssembly renderer. The WASM binary needs to be accessible — in a Vite project, put it in public/:
node_modules/@lottiefiles/dotlottie-web/dist/dotlottie-player.wasm → public/
A basic player component:
import { useEffect, useRef } from 'react';
import { DotLottie } from '@lottiefiles/dotlottie-web';
interface DotLottiePlayerProps {
src: string;
loop?: boolean;
autoplay?: boolean;
className?: string;
}
export function DotLottiePlayer({ src, loop = true, autoplay = true, className }: DotLottiePlayerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const playerRef = useRef<DotLottie | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
playerRef.current = new DotLottie({
canvas: canvasRef.current,
src,
loop,
autoplay,
renderConfig: {
devicePixelRatio: window.devicePixelRatio,
},
});
return () => {
playerRef.current?.destroy();
};
}, [src]);
return <canvas ref={canvasRef} className={className} />;
}
The canvas API is what differentiates this from the SVG-based lottie-web. The WASM renderer draws directly to canvas — better performance for complex animations, no DOM overhead.
Controlling playback from React state
The static player above is fine for looping backgrounds. The interesting case is state-driven animation — where React state determines which animation plays and when.
The IndicatorButton in the Config demo had this exact requirement: two dotLottie animations (idle and active), and React state saying which character is currently selected. On state change, pause the idle, seek to frame 0 on the active, play.
import { useEffect, useRef } from 'react';
import { DotLottie } from '@lottiefiles/dotlottie-web';
import { motion } from 'framer-motion';
interface IndicatorButtonProps {
isActive: boolean;
onClick: () => void;
idleSrc: string;
activeSrc: string;
}
export function IndicatorButton({ isActive, onClick, idleSrc, activeSrc }: IndicatorButtonProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const idleRef = useRef<DotLottie | null>(null);
const activeRef = useRef<DotLottie | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
// Load both animations, only one plays at a time
idleRef.current = new DotLottie({
canvas: canvasRef.current,
src: idleSrc,
loop: true,
autoplay: true,
});
activeRef.current = new DotLottie({
canvas: canvasRef.current,
src: activeSrc,
loop: false,
autoplay: false,
});
return () => {
idleRef.current?.destroy();
activeRef.current?.destroy();
};
}, []);
useEffect(() => {
if (isActive) {
idleRef.current?.pause();
activeRef.current?.seek(0);
activeRef.current?.play();
} else {
activeRef.current?.pause();
idleRef.current?.seek(0);
idleRef.current?.play();
}
}, [isActive]);
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="indicator-btn"
>
<canvas ref={canvasRef} width={48} height={48} />
</motion.button>
);
}
The key detail: seek(0) before play(). Without the seek, the animation resumes from wherever it paused — which looks wrong when you’re switching states. Seek to the start, then play.
The Framer Motion whileHover and whileTap on the button wrapper is the second layer — the interaction response you add in React, on top of the animation you already defined in Figma. The dotLottie handles the animation content. Framer Motion handles the interactivity feel.
Where Framer Motion takes over
Anything that responds to React state directly — page-level transitions, component entrance/exit, text appearing as characters switch — belongs to Framer Motion, not dotLottie.
In the Config demo, switching characters triggered:
import { motion, AnimatePresence } from 'framer-motion';
function CharacterInfo({ character, visible }: { character: Character; visible: boolean }) {
return (
<AnimatePresence mode="wait">
{visible && (
<motion.div
key={character.id}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
>
<h1 style={{ color: character.accentColor }}>{character.name}</h1>
<p>{character.title}</p>
<p>{character.description}</p>
</motion.div>
)}
</AnimatePresence>
);
}
The AnimatePresence mode="wait" waits for the exit animation to finish before mounting the new character. The easing [0.22, 1, 0.36, 1] is a fast-start, soft-land curve — characters don’t fade symmetrically, they arrive more deliberately than they leave.
This part has nothing to do with dotLottie. It’s pure React state expressed as motion.
The canvas stacking problem
One thing that’s not obvious until you hit it: if you’re loading multiple dotLottie animations for the same component — idle + active in the indicator example — you need a canvas per animation, not one canvas shared between them.
The WASM renderer draws to a specific canvas element. If you switch src on the same DotLottie instance, you get a flash while the new animation loads. Two instances, two canvases, one visible at a time via CSS opacity or absolute positioning.
// Two canvases, stacked
<div style={{ position: 'relative', width: 48, height: 48 }}>
<canvas ref={idleCanvasRef} style={{ position: 'absolute', opacity: isActive ? 0 : 1, transition: 'opacity 0.2s' }} />
<canvas ref={activeCanvasRef} style={{ position: 'absolute', opacity: isActive ? 1 : 0, transition: 'opacity 0.2s' }} />
</div>
The crossfade between the two canvases is a CSS transition — nothing dotLottie or Framer Motion needs to know about.
What breaks in the Figma export
After doing this enough times, these are the things to check before assuming the export is clean:
Nested component states — Smart Animate between variants that contain other component variants. The plugin captures the outer transition but sometimes flattens nested state changes. Check the exported animation against the Figma prototype frame by frame.
Text animations — Figma Smart Animate interpolates position and opacity on text layers, but variable fonts and certain text properties don’t export cleanly. If there’s animated text in the Figma component, check it first.
Easing — Figma’s Spring easing doesn’t map directly to what dotLottie can express. The export approximates it with a cubic bezier. Usually close enough; sometimes you’ll need to tweak the resulting curve in the LottieFiles editor.
Frame rate — dotLottie defaults to the frame rate set in the Figma plugin at export time (usually 30fps). If the animation looks slightly off from the prototype, check if the Figma file was designed at a different rate.
When this pipeline makes sense
It’s the right call when you’ve built the animation in Figma — prototyped the transitions, tuned the timing, iterated until it’s right. The export captures that work exactly. You’re not recreating it in code, you’re shipping it.
It’s the wrong call when you’re exporting Figma components just to get a spinner or a hover effect that could’ve been 12 lines of CSS. The WASM runtime, the canvas setup, the file hosting — it’s overhead that only pays off when the animation is genuinely complex and worth preserving precisely.
The Config demo was worth it. I’d spent real time on those animations in Figma. Recreating them in CSS or Framer Motion would have meant losing something in translation. dotLottie meant what I built in Figma was what shipped in the browser.
That’s the actual argument for this pipeline — not that it’s elegant, but that it closes the gap between the tool you designed in and the thing that runs in production.