Custom motion
Replace the draw animation entirely with your own Motion variants.
The variants prop is the low-level escape hatch. Supply your own Motion variants and the engine skips its built-in animation, using your variants directly.
Try the curated route first
For the icon's character animation — a heart beat, a bell ring, a loader spin — use mode="signature". Modes compose with duration/delay/stagger/easing/repeat automatically, whereas variants ignores those timing props entirely. Reach for variants (or a custom mode factory) when you need motion that isn't expressible as a built-in mode.
When to reach for this
- You want bespoke per-stroke motion that's tightly coupled to a specific icon's path structure.
- You want to bypass the mode system and write Motion variants directly.
- You want to compose multiple motion properties in ways the built-in modes don't.
For most apps, mode covers the common cases. Reach for variants only when modes aren't enough.
Two forms
The variants prop accepts either an object (same variants for every stroke) or a function that receives the stroke index and returns variants per-stroke.
Object form
Same variants applied to every SVG element. Best for whole-icon motion.
pulsespin<Heart
size={48}
variants={{
rest: { scale: 1 },
active: { scale: [1, 1.3, 1], transition: { duration: 0.4 } },
}}
style={{ transformOrigin: "12px 12px", transformBox: "view-box" }}
/>
<Settings
size={48}
variants={{
rest: { rotate: 0 },
active: { rotate: 360, transition: { duration: 0.6 } },
}}
style={{ transformOrigin: "12px 12px", transformBox: "view-box" }}
/>Function form
Receives the stroke index i, returns variants. Use when you want per-stroke staggered motion.
<Sparkles
size={48}
variants={(i) => ({
rest: { rotate: 0 },
active: {
rotate: [0, -15, 12, -8, 0],
transition: { duration: 0.5, delay: i * 0.06 },
},
})}
style={{ transformOrigin: "12px 12px", transformBox: "view-box" }}
/>Rules
- You must expose
restandactivevariant names. The trigger system switches between these two — no other names will fire. - Built-in timing props are ignored when
variantsis set. Your variants own the transition entirely. - For transform-based motion (
rotate,scale), settransformOrigin: "12px 12px"andtransformBox: "view-box"on the element'sstyle. Without this, transforms happen from the top-left of each path instead of the icon center.
style={{ transformOrigin: "12px 12px", transformBox: "view-box" }}The 12px 12px aligns with Lucide's viewBox="0 0 24 24".
The default draw, for reference
Here's what the built-in draw variants look like, so you can see the shape the engine expects. The draw animates strokeDashoffset from the path's measured length down to 0 against a matching strokeDasharray, then clears both at rest so the resting DOM carries no dash attributes (this is what keeps closed-path icons like the gear or heart seam-free):
// `ctx` is the ModeContext the engine passes a mode factory. `ctx.pathLength`
// is each element's measured length from getTotalLength() — it's only
// available inside a `mode` factory, not when you hand-write `variants`.
(ctx) => ({
rest: {
strokeDasharray: 0,
strokeDashoffset: 0,
opacity: 1,
},
active: {
strokeDasharray: ctx.pathLength,
strokeDashoffset: [ctx.pathLength, 0],
opacity: [0.25, 1],
transition: {
duration: ctx.duration, // 0.55 default
delay: ctx.delay + ctx.index * ctx.stagger, // stagger 0.12 default
ease: ctx.easing, // "easeInOut" default
repeat: ctx.repeat, // 0 default
repeatType: "loop",
strokeDasharray: { duration: 0 }, // snap, don't tween
},
transitionEnd: { strokeDasharray: 0, strokeDashoffset: 0 },
},
})Because ctx.pathLength is engine-measured, this exact draw can only be expressed as a mode factory. A hand-written variants object can't read it — which is why custom stroke-draws are usually better built as a custom mode than as raw variants.
Trigger system still works
Every trigger mode applies your custom variants the same way it applies the default ones. A pulse variant pulses on hover, click, mount, in-view, parent-hover, or manual play — all without extra code.
<Heart
trigger="click"
variants={{
rest: { scale: 1 },
active: { scale: [1, 1.3, 1], transition: { duration: 0.4 } },
}}
style={{ transformOrigin: "12px 12px", transformBox: "view-box" }}
/>