Lucide Motion

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.

pulse
spin
<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

  1. You must expose rest and active variant names. The trigger system switches between these two — no other names will fire.
  2. Built-in timing props are ignored when variants is set. Your variants own the transition entirely.
  3. For transform-based motion (rotate, scale), set transformOrigin: "12px 12px" and transformBox: "view-box" on the element's style. 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" }}
/>

On this page