lucide-animated: Icon Motion That Knows When to Stop

Published

2 Aug 2026

Hover over the copy button on any code block on this site and the icon does a small clipboard flip before you've even clicked it. That took about fifteen minutes to wire in and, if it's doing its job, roughly zero seconds for anyone to consciously notice. That's the whole pitch for lucide-animated: tiny, purposeful icon motion, not a light show.

I use lucide-react everywhere on this site, in nav items, buttons, code blocks, the lot. It's static by design, which is correct for the vast majority of icons on a page. But there's a small category of icon that exists specifically to give feedback, a copy icon, a like icon, a bell that just got a notification, and a plain static glyph undersells the moment. lucide-animated is the same icon set with that specific gap filled in.

Same icons, one extra letter

lucide-animated isn't a new icon set you have to learn. It's the lucide library rebuilt with Motion baked into each icon, so Heart becomes HeartIcon, Copy becomes CopyIcon, and so on for the roughly 430 icons it currently covers. Same size prop, same currentColor stroke, same className for styling. If you already know lucide-react, you already know this API.

bash
pnpm add lucide-animated# peer deps: react (>=18) and motion (>=11)
tsx
import { HeartIcon } from "lucide-animated"
export function LikeIcon() {  return <HeartIcon className="text-on-surface-variant" size={18} />}

Drop that into a page and hovering the icon plays a short animation, a bounce, a scale, a couple of pulses depending on the icon, then move your mouse away and it settles back down. It's genuinely a drop-in replacement everywhere you'd otherwise import from lucide-react, hover in, hover out, done. What that settling actually relies on is worth understanding before you reach for the other mode this library has, more on that below.

Here's a sample of the set. Flip the switch off to see the same icons rendered as the plain lucide-react versions I started with, then flip it back, hover a card, or hit the small play button in its corner:

Terminal
GitBranch
GitPullRequest
Rocket
Sparkles
Bell
Heart
Send
Download
Search
Settings
Copy
Bookmark
MessageCircle
Zap

Hover is the easy 80%, and also the least interesting one

The default hover trigger covers a lot of ground for free: a nav icon, a card someone's already moving a mouse over out of curiosity. It's a nice touch and it costs nothing to add.

It's also a weak signal for the icons that matter most. A touch device has no hover at all, so a hover-only animation on a copy button or a like button simply never fires for a meaningful chunk of your visitors. And the interaction that actually deserves an animated response, the click that did the copying or the tap that just liked something, isn't a mouseover to begin with.

lucide-animated has an answer for this, and it's the part of the library I actually reach for: attach a ref and the icon switches into controlled mode. The built-in hover trigger turns itself off, and the animation only fires when you call startAnimation() yourself.

tsx
import { useRef, useState } from "react"import { useReducedMotion } from "framer-motion"import { HeartIcon } from "lucide-animated"
export function LikeButton() {  const [liked, setLiked] = useState(false)  const iconRef = useRef(null)  const shouldReduceMotion = useReducedMotion()
  function handleClick() {    const nextLiked = !liked    setLiked(nextLiked)    if (nextLiked && !shouldReduceMotion) {      iconRef.current?.startAnimation()    }  }
  return (    <button onClick={handleClick} aria-pressed={liked}>      <HeartIcon        ref={iconRef}        size={16}        className={liked ? "text-primary" : "text-on-surface-variant"}      />      {liked ? "Liked" : "Like"}    </button>  )}

That's a real, if deliberately small, example of the difference: the pulse fires on the click that actually changed state, works identically on a phone, and is reachable from a keyboard because it's sitting on a real <button> rather than a :hover selector. I kept the liked state as a colour change rather than a solid heart fill too, mostly because a filled red heart would be the one icon on this entire site wearing a colour that isn't indigo, and that felt like the wrong hill to die on for a demo.

Controlled mode doesn't know how to stop either

There's a detail I glossed over above, and it's worth a real example rather than a footnote. The LikeButton component works because Heart happens to be one of the icons whose animation ends where it started, scale bounces up to 1.08 and back down to 1, so once it finishes playing it looks reverted even though nothing explicitly told it to. Not every icon does that.

Pull up the source for the icon element docs and you'll find Terminal's cursor line is set to repeat: Number.POSITIVE_INFINITY, it blinks forever once triggered, the same way a real terminal cursor does. Rocket's wobble is the same deal. Copy's clipboard icon moves to a fixed offset and just sits there. None of that is a bug, it's a reasonable default for a component built around two assumptions: either you're hovering it, in which case onMouseLeave calls stopAnimation() for you automatically, or you attached a ref, in which case you're expected to call it yourself when you're done. There's no third option where it just knows.

I found this the hard way building the playground grid above. The first version called startAnimation() on hover and on the play button and nothing else. Hover Terminal once and its cursor kept blinking for the rest of the session, nothing short of a page refresh would stop it. Here's the actual fix:

tsx
function handleMouseLeave() {  clearStopTimeout()  iconRef.current?.stopAnimation()}
function handlePlay() {  clearStopTimeout()  iconRef.current?.startAnimation()  // A click has no equivalent to mouseleave, so time-box it instead.  stopTimeoutRef.current = window.setTimeout(() => {    iconRef.current?.stopAnimation()  }, 1500)}

handleMouseLeave is just reimplementing what the library already does for you in its own hover mode, by hand, because attaching a ref opts you out of that default. The timeout on the play button exists because a click has no "the mouse left" moment to hook into, so 1.5 seconds stands in for one. Every controlled usage on this site now either pairs a startAnimation() with a stopAnimation() somewhere, or leans on an animation that's self-limiting by design, like Heart. If you can't immediately tell which one your icon is from reading its source, assume it isn't and write the revert anyway.

The part it doesn't do for you

lucide-animated has no idea prefers-reduced-motion exists. There's no built-in check, no opt-out prop that reads the media query for you, nothing. Every single usage on this site gates animateOnHover (or the ref call, in controlled mode) behind useReducedMotion() from framer-motion, which was already the standard hook this site uses for every other bit of motion. That's not a nice-to-have I added because I felt like being thorough, it's the one non-negotiable rule that comes with using this library at all. If you reach for lucide-animated and skip this step, you've shipped an accessibility regression with a nice demo GIF attached to the PR.

tsx
import { useReducedMotion } from "framer-motion"import { HeartIcon } from "lucide-animated"
export function LikeIcon() {  const shouldReduceMotion = useReducedMotion()
  return (    <HeartIcon      className="text-on-surface-variant"      size={18}      animateOnHover={!shouldReduceMotion}    />  )}

One exception worth calling out: the icon playground above always plays on click, reduced motion setting or not. That's deliberate, not an oversight. Clicking a documentation example specifically to preview an animation is a different thing to motion firing on its own while you're just trying to read a page, the same distinction that lets a video keep a visible, clickable play button under prefers-reduced-motion. Production usage doesn't get that excuse.

Where I actually put it

I went looking for the handful of spots on this site where an icon already exists purely to confirm an action, and found three: the copy button on every code snippet in the Elements docs, the same copy button inside the HTML/CSS/JS live preview component, and the copy button on inline prompt examples in posts. All three were the identical five-line change, swap the lucide-react import for the animated one, add the useReducedMotion gate.

jsx
// beforeimport { Check, Copy } from "lucide-react"
{copied ? <Check size={14} /> : <Copy size={14} />}
// afterimport { useReducedMotion } from "framer-motion"import { CheckIcon, CopyIcon } from "lucide-animated"
const shouldReduceMotion = useReducedMotion()const Icon = copied ? CheckIcon : CopyIcon
<Icon size={14} animateOnHover={!shouldReduceMotion} />

If you've been reading this post inside a code block on this site, and you're on a machine with a mouse, you've already hovered over one of these without noticing. That's the outcome I was after, not "look at this animation" but "huh, that felt nice," several seconds after the fact if at all.

Where I left it alone

I didn't touch the dozens of other icons across the site, the ones in the nav, in cards, next to form fields. Those icons are labels, not feedback. Animating a settings gear or a search icon just because the library makes it easy would be adding motion because it's available, not because anything asked for it, and that's exactly the instinct worth resisting. A copy button that pulses once when the action succeeds reads as polish. A page where six icons are all doing their own little dance because someone installed a fun package reads as a screensaver from 2003.

The test I settled on, and the one I'd suggest if you're deciding whether an icon deserves this: does the icon's job include telling the user something just happened? A copy that succeeded, a like that registered, a save that completed, yes. A folder icon sitting next to the word "Folder," no. If you can't name the specific interaction the animation is confirming, it's decoration wearing a utility's name tag, and it should probably stay a plain lucide-react import.