Every project eventually needs animated icons. A spinner, a checkmark that draws in on success, a hamburger that morphs to a close button. Simple enough concept. The execution options range from “a few lines of CSS” to “a 200kb runtime dependency” — and the choice matters more than most people think.
I’ve worked close enough to some of these formats to know what’s actually happening under the hood — and I still don’t think there’s one right answer. But most teams reach for the wrong tool first.
The options
- SVG from scratch — hand-written or Figma-exported SVG, animated with CSS
- Lottie — the
.jsonformat and one of several JavaScript runtimes - dotLottie —
.lottiefiles, which are not the same as Lottie despite sharing a name - Icon libraries with built-in animation — Iconify, Lucide Motion, Phosphor Animated
- Rive — a different runtime entirely, worth knowing about
SVG from scratch
For most icon animations, this is the right answer — and the one teams skip because it feels like more work than it is.
A CSS-only loading spinner:
@keyframes spin-stroke {
0% { stroke-dashoffset: 100; transform: rotate(0deg); }
50% { stroke-dashoffset: 20; }
100% { stroke-dashoffset: 100; transform: rotate(360deg); }
}
.spinner {
transform-origin: center;
animation: spin-stroke 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
A success checkmark that draws in:
.check-circle {
transform-origin: center;
animation: pop-circle 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: 0.1s;
}
.check-mark {
stroke-dasharray: 50;
stroke-dashoffset: 50;
animation: draw-check 0.5s ease forwards;
animation-delay: 0.3s;
}
Bundle size: zero. Runtime dependency: none. Customization: complete — it’s just CSS.
The limitation: complex multi-layer animations with path morphing are genuinely hard to do by hand. That’s where Lottie earns its place.
As a React component
An animated icon as a typed React component — trigger the animation on mount or via a prop:
import { useEffect, useRef } from 'react';
interface SpinnerProps {
size?: number;
color?: string;
}
export function Spinner({ size = 24, color = 'currentColor' }: SpinnerProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 40 40"
aria-label="Loading"
role="status"
>
<circle
cx="20" cy="20" r="15"
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="2.5"
/>
<circle
cx="20" cy="20" r="15"
fill="none"
stroke={color}
strokeWidth="2.5"
strokeDasharray="94"
strokeLinecap="round"
style={{
transformOrigin: 'center',
animation: 'spin-stroke 1.4s cubic-bezier(0.4,0,0.2,1) infinite',
}}
/>
</svg>
);
}
The @keyframes goes in a global stylesheet or CSS module. The component stays clean — props control size and color, the animation runs in CSS. No state, no refs, no JavaScript animation loop.
For an icon that animates on interaction rather than on mount, a data-state attribute paired with CSS selectors keeps the logic in CSS where it belongs:
export function MenuIcon({ isOpen }: { isOpen: boolean }) {
return (
<svg
width="24" height="24" viewBox="0 0 24 24"
data-state={isOpen ? 'open' : 'closed'}
className="menu-icon"
>
<line x1="3" y1="7" x2="21" y2="7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="line-top"/>
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="line-mid"/>
<line x1="3" y1="17" x2="21" y2="17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="line-bot"/>
</svg>
);
}
.menu-icon .line-top,
.menu-icon .line-bot {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: center;
}
.menu-icon .line-mid {
transition: opacity 0.2s ease;
}
.menu-icon[data-state="open"] .line-top {
transform: translateY(5px) rotate(45deg);
}
.menu-icon[data-state="open"] .line-mid {
opacity: 0;
}
.menu-icon[data-state="open"] .line-bot {
transform: translateY(-5px) rotate(-45deg);
}
No animation library. No extra dependency. Works with any React version.
Lottie
Lottie is a JSON format — originally created by Airbnb, now maintained by the community — that describes animations exported from After Effects. A Lottie file (.json) contains keyframes, shapes, layers, and timing data. To play it in a browser you need a JavaScript runtime.
There are several runtimes:
- lottie-web — the original, ~247kb minified, feature-complete
- @lottiefiles/lottie-player — LottieFiles’ web component wrapper
- lottie-react, lottie-vue, etc — framework wrappers around lottie-web
The bundle cost is real. Even lottie-web with tree-shaking lands around 60–100kb gzipped depending on which renderer you use (SVG, canvas, or HTML). For a single spinner that could’ve been 12 lines of CSS, that’s a hard trade to justify.
Where Lottie genuinely wins:
- Designer-authored animations from After Effects that are too complex to recreate by hand
- Multi-layer path morphing — a character blinking, a logo transform, anything with shape interpolation
- Brand animations where the designer owns the exact timing and motion
The problem I see most often: teams install lottie-web for a loading spinner and a checkmark. Neither of those needed Lottie. The complex brand animation on the homepage did.
dotLottie — not the same thing
This is the one most people get wrong. dotLottie (.lottie) is a different format from Lottie (.json). Different file extension, different container format, different runtime.
I worked on the dotLottie format and the interactivity runtime at LottieFiles. Here’s what it actually is: a .lottie file is a zip container. Inside it: the animation data (still JSON-compatible), bundled assets like fonts and images, and optionally a state machine for interactivity. The zip compression means .lottie files are typically 30–50% smaller than the equivalent .json.
The interactivity layer is the genuinely interesting part. Instead of wiring up click handlers in JavaScript to pause/play/seek a Lottie animation, a state machine in the .lottie file defines transitions between animation states:
{
"states": {
"idle": { "animationSegment": [0, 30] },
"hover": { "animationSegment": [30, 60] }
},
"transitions": [
{ "from": "idle", "to": "hover", "trigger": "onHover" },
{ "from": "hover", "to": "idle", "trigger": "onLeave" }
]
}
The runtime handles the event listeners and state transitions. Your component stays clean — no useEffect managing play states. I gave a talk about this at Figma Config in San Francisco last year because I think the state machine approach to icon interactivity is genuinely underexplored. Most people use Lottie as a video player. The interactivity runtime is closer to XState for animations.
The catch: you need the dotLottie runtime (@lottiefiles/dotlottie-web), not lottie-web. They’re not interchangeable, and the dotLottie player is newer with a smaller surface area of third-party integrations.
When to use dotLottie: when your designer is delivering complex animations from LottieFiles and you want built-in interactivity without wiring it up manually. When you care about file size. When you’re already in the LottieFiles ecosystem.
Rive
Worth a mention because it’s architecturally different. Rive uses a binary format and a state machine-first design tool — you define states and transitions in Rive’s editor, not in After Effects. The runtime (@rive-app/canvas) is around 50kb gzipped.
The limitation: it requires the Rive tool specifically. You can’t export from Figma or After Effects into Rive. If your motion designer doesn’t work in Rive, this option isn’t available to you.
Icon libraries with animation
Iconify Animated and Phosphor Animated give you pre-made animated icon sets as components. The tradeoff: you’re constrained to whatever animations the library provides. The moment a designer hands you a custom icon that isn’t in the library, you’re writing SVG by hand anyway.
I’ve found these most useful in internal tools and dashboards where design consistency matters more than custom motion. For a product with a strong design system and custom iconography, they’re usually not the right fit.
Comparison
| Bundle | Customization | Complex animation | Interactivity | |
|---|---|---|---|---|
| SVG + CSS | 0kb | Full | Hard | CSS / JS |
| Lottie (json) | 60–100kb gz | Limited | Excellent | Manual |
| dotLottie (.lottie) | ~40kb gz | Limited | Excellent | State machine |
| Rive | ~50kb gz | Rive tool only | Excellent | State machine |
| Icon library | varies | None | None | None |
The customization problem
Here’s the scenario nobody writes about: your designer opens Figma, draws a beautiful custom icon with a specific micro-interaction — a send button where the paper plane takes off at a slight angle, trails a motion path, and settles. It’s 3 seconds long, carefully timed, and it’s not in any library.
Your options:
-
Export to Lottie via a Figma plugin (LottieFiles, Jitter, Easing) — works well if the animation was built in a Lottie-compatible way. Path morphing and some effects don’t export cleanly.
-
Recreate it in SVG + CSS — more work upfront, but you own it completely. CSS
offset-pathhandles motion path animations.@keyframeshandles everything else. The output is zero-dependency and fully customizable. -
Rive — if your designer is willing to rebuild it in Rive’s editor. The motion tool is good. The context switch is real.
The honest answer is that options 1 and 2 are the most common, and the choice comes down to how complex the animation is. Under a certain complexity threshold — which covers most UI micro-interactions — CSS is faster to ship and easier to maintain than any runtime.
The Lottie ecosystem exists because After Effects is where a lot of motion designers live, and Lottie bridges that world to the browser. That bridge is valuable. Just make sure you actually need to cross it.