# Animated Icon

Animate any Lucide icon with one of twelve presets (wiggle, bounce, spin, pulse, draw, shake, pop, float, tada, flip, swing, nudge), played on hover, on its button's hover, on click, on load or on a loop.

**Category:** Icons  
**Exports:** `AnimatedIcon`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/animated-icon.json
```
The file lands at `components/ui/animated-icon.tsx`; import from `@/components/ui/animated-icon`.

## API — Animated Icon
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `icon` | `ComponentType` | — | Any Lucide icon, e.g. Bell. |
| `animation` | `"wiggle" \| "bounce" \| "spin" \| "pulse" \| "draw" \| "shake" \| "pop" \| "float" \| "tada" \| "flip" \| "swing" \| "nudge"` | `"wiggle"` |  |
| `trigger` | `"hover" \| "parent" \| "click" \| "loop" \| "mount" \| "none"` | `"hover"` |  |
| `animate` | `boolean` | — | Play when this turns true. |
| `size` | `number` | `24` |  |
| `strokeWidth` | `number` | `2` |  |
| `speed` | `number` | `1` |  |
| `className` | `string` | — |  |

## Example — Animated Icon

```tsx
import { Bell, Download, Heart, Mail, RefreshCw, Rocket, Search, Settings, Sparkles, Star, ThumbsUp, Zap } from "lucide-react";
import { AnimatedIcon, type IconAnimation } from "@/components/ui/animated-icon";

const rows: [IconAnimation, typeof Bell][] = [
  ["wiggle", Bell],
  ["bounce", Download],
  ["spin", RefreshCw],
  ["pulse", Heart],
  ["draw", Sparkles],
  ["shake", Search],
  ["pop", Star],
  ["float", Rocket],
  ["tada", ThumbsUp],
  ["flip", Mail],
  ["swing", Zap],
  ["nudge", Settings],
];

export default function AnimatedIconDemo({ speed = 1, loop = false }: { speed?: number; loop?: boolean }) {
  return (
    <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
      {rows.map(([a, Icon]) => (
        <button key={a} type="button" className="flex w-24 cursor-pointer flex-col items-center gap-2 rounded-xl border bg-card px-3 py-4 text-xs text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
          <AnimatedIcon key={String(loop)} icon={Icon} animation={a} trigger={loop ? "loop" : "parent"} speed={speed} className="text-foreground" />
          {a}
        </button>
      ))}
    </div>
  );
}
```

## Source — components/ui/animated-icon.tsx

```tsx
"use client";

import * as React from "react";
import { animate, useReducedMotion, type DOMKeyframesDefinition } from "motion/react";
import { cn } from "@/lib/utils";

export type IconAnimation = "wiggle" | "bounce" | "spin" | "pulse" | "draw" | "shake" | "pop" | "float" | "tada" | "flip" | "swing" | "nudge";

export type AnimatedIconEngineProps = {
  /** Any Lucide icon component, or anything that renders a stroked SVG. */
  icon: React.ComponentType<{ size?: number; strokeWidth?: number; className?: string }>;
  animation?: IconAnimation;
  /** "hover" plays on the icon's own hover, "parent" on its parent's hover or focus, "click" on press, "loop" forever, "mount" once on load, "none" only when `animate` changes to true. */
  trigger?: "hover" | "parent" | "click" | "loop" | "mount" | "none";
  animate?: boolean;
  size?: number;
  strokeWidth?: number;
  /** Speed multiplier. */
  speed?: number;
  className?: string;
};

// Keyframes and timing for each preset, applied to the wrapper (or, for draw, to every stroke).
const presets: Record<Exclude<IconAnimation, "draw">, { k: DOMKeyframesDefinition; d: number; origin?: string }> = {
  wiggle: { k: { rotate: [0, -12, 10, -6, 4, 0] }, d: 0.6 },
  bounce: { k: { y: [0, -5, 0, -2, 0] }, d: 0.6 },
  spin: { k: { rotate: [0, 360] }, d: 0.7 },
  pulse: { k: { scale: [1, 1.2, 0.95, 1.05, 1] }, d: 0.6 },
  shake: { k: { x: [0, -3, 3, -2, 2, 0] }, d: 0.45 },
  pop: { k: { scale: [1, 0.7, 1.25, 1] }, d: 0.45 },
  float: { k: { y: [0, -3, 0] }, d: 1.4 },
  tada: { k: { scale: [1, 0.9, 1.1, 1.1, 1], rotate: [0, -6, 6, -6, 0] }, d: 0.8 },
  flip: { k: { rotateY: [0, 180, 360] }, d: 0.7 },
  swing: { k: { rotate: [0, 15, -10, 5, 0] }, d: 0.8, origin: "50% 0%" },
  nudge: { k: { x: [0, 4, -1, 0] }, d: 0.5 },
};

const STROKES = "path, line, polyline, polygon, circle, ellipse, rect";

/** Animates any Lucide icon with a preset (wiggle, bounce, spin, draw and more), on hover, on its parent's hover, on click, on load or on a loop. */
export function AnimatedIcon({ icon: Icon, animation = "wiggle", trigger = "hover", animate: controlled, size = 24, strokeWidth = 2, speed = 1, className }: AnimatedIconEngineProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLSpanElement>(null);
  const live = React.useRef({ animation, speed });
  live.current = { animation, speed };
  const running = React.useRef<{ stop: () => void }[]>([]);

  const play = React.useCallback(
    (loop = false) => {
      const el = ref.current;
      if (!el || reduce) return;
      running.current.forEach((a) => a.stop());
      const { animation: a, speed: s } = live.current;
      const repeat = loop ? { repeat: Infinity, repeatDelay: 0.4 / s } : {};
      if (a === "draw") {
        // pathLength="1" normalises every stroke, so one dash offset draws any shape.
        const strokes = [...el.querySelectorAll<SVGGeometryElement>(STROKES)];
        running.current = strokes.map((p, i) => {
          p.setAttribute("pathLength", "1");
          p.style.strokeDasharray = "1";
          return animate(p, { strokeDashoffset: [1, 0] }, { duration: 0.6 / s, delay: (i * 0.08) / s, ease: "easeOut", ...repeat });
        });
        return;
      }
      const p = presets[a];
      el.style.transformOrigin = p.origin ?? "50% 50%";
      running.current = [animate(el, p.k, { duration: p.d / s, ease: "easeInOut", ...repeat })];
    },
    [reduce],
  );

  React.useEffect(() => {
    const el = ref.current;
    if (!el || reduce) return;
    if (trigger === "loop") {
      play(true);
      return () => running.current.forEach((a) => a.stop());
    }
    if (trigger === "mount") play();
    const target = trigger === "parent" ? el.parentElement : el;
    const event = trigger === "click" ? "pointerdown" : trigger === "hover" || trigger === "parent" ? "pointerenter" : null;
    if (!target || !event) return;
    const go = () => play();
    target.addEventListener(event, go);
    if (event === "pointerenter") target.addEventListener("focusin", go);
    return () => {
      target.removeEventListener(event, go);
      target.removeEventListener("focusin", go);
    };
  }, [trigger, play, reduce, animation]);

  React.useEffect(() => {
    if (controlled) play();
  }, [controlled, play]);

  return (
    <span ref={ref} aria-hidden className={cn("inline-flex shrink-0 [&_svg]:overflow-visible", className)} style={{ width: size, height: size }}>
      <Icon size={size} strokeWidth={strokeWidth} />
    </span>
  );
}
```