Animated Icon
Wrap any SVG icon to animate it on hover, on a loop, or once on mount. Seven presets, pure CSS, and off under reduced motion.
betaAnimatedIcon.tsx
Examples
Animations
Hover a tile. draw traces the strokes; the rest move the whole glyph.
- draw
- spin
- bounce
- wiggle
- pulse
- shake
- pop
animated-icon/animations.tsx
import { Bell, Heart, PenLine, Rocket, Settings, Smartphone, Sparkles } from "lucide-react";
import { AnimatedIcon, type IconAnimation } from "@streamui/react";
const demos: { animation: IconAnimation; icon: typeof Rocket }[] = [
{ animation: "draw", icon: PenLine },
{ animation: "spin", icon: Settings },
{ animation: "bounce", icon: Rocket },
{ animation: "wiggle", icon: Bell },
{ animation: "pulse", icon: Heart },
{ animation: "shake", icon: Smartphone },
{ animation: "pop", icon: Sparkles },
];
export default function AnimatedIconAnimations() {
return (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{demos.map(({ animation, icon: Icon }) => (
// Hovering the tile animates the icon: the tile is its direct parent.
<li
key={animation}
className="flex cursor-default flex-col items-center gap-2 border-2 border-ink bg-paper-raised px-6 py-4 shadow-brutal-sm hover:bg-lime-soft"
>
<AnimatedIcon animation={animation}>
<Icon aria-hidden className="size-7" strokeWidth={1.75} />
</AnimatedIcon>
<span className="font-mono text-xs">{animation}</span>
</li>
))}
</ul>
);
}Triggers
loop for busy states, once for confirmations, hover for everything else.
Syncing…
Saved
Hover me
animated-icon/triggers.tsx
import { CircleCheck, LoaderCircle, MousePointerClick } from "lucide-react";
import { AnimatedIcon } from "@streamui/react";
export default function AnimatedIconTriggers() {
return (
<div className="flex flex-wrap items-center justify-center gap-8">
<p className="flex items-center gap-2 font-semibold">
<AnimatedIcon animation="spin" trigger="loop" duration={900}>
<LoaderCircle aria-hidden className="size-5" />
</AnimatedIcon>
Syncing…
</p>
<p className="flex items-center gap-2 font-semibold">
<AnimatedIcon animation="draw" trigger="once" duration={900}>
<CircleCheck aria-hidden className="size-5" />
</AnimatedIcon>
Saved
</p>
<p className="flex items-center gap-2 font-semibold">
<AnimatedIcon animation="wiggle">
<MousePointerClick aria-hidden className="size-5" />
</AnimatedIcon>
Hover me
</p>
</div>
);
}Inside a button
With the hover trigger, hovering or focusing the parent button plays the animation.
animated-icon/in-button.tsx
import { Download, Heart, RefreshCw, Send } from "lucide-react";
import { AnimatedIcon, Button } from "@streamui/react";
export default function AnimatedIconInButton() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<Button>
<AnimatedIcon animation="bounce">
<Download aria-hidden />
</AnimatedIcon>
Download
</Button>
<Button variant="secondary">
<AnimatedIcon animation="spin">
<RefreshCw aria-hidden />
</AnimatedIcon>
Refresh
</Button>
<Button variant="pink">
<AnimatedIcon animation="pop">
<Heart aria-hidden />
</AnimatedIcon>
Like
</Button>
<Button variant="blue">
Send
<AnimatedIcon animation="shake">
<Send aria-hidden />
</AnimatedIcon>
</Button>
</div>
);
}Installation
bun add @streamui/reactusage.tsx
import { Rocket } from "lucide-react";
import { AnimatedIcon } from "@streamui/react";
<AnimatedIcon animation="bounce" trigger="hover">
<Rocket aria-hidden />
</AnimatedIcon>API
| Prop | Type | Default | Description |
|---|---|---|---|
| animation | "draw" | "spin" | "bounce" | "wiggle" | "pulse" | "shake" | "pop" | — | Which preset to play. Required. |
| trigger | "hover" | "loop" | "once" | "hover" | hover plays while the icon or its direct parent is hovered or focused. |
| duration | number | 700 | Length of one cycle in milliseconds. |
| children | ReactNode | — | The SVG icon to animate (lucide-react, react-icons, or your own). |
| ...props | ComponentProps<"span"> | — | Native span attributes for the wrapper. |
Accessibility
- Every animation stops under prefers-reduced-motion; the icon stays fully drawn and still.
- Animation is decoration: keep the icon aria-hidden and put the meaning in text or an aria-label.
- Avoid loop for anything that isn't an active process, like loading or syncing.
Source
packages/react/src/AnimatedIcon.tsx
"use client";
import { useEffect, useRef, type ComponentProps, type CSSProperties } from "react";
import { cn } from "./cn";
export const iconAnimations = ["draw", "spin", "bounce", "wiggle", "pulse", "shake", "pop"] as const;
export type IconAnimation = (typeof iconAnimations)[number];
/**
* - `hover`: plays while the icon, or its direct parent (e.g. a button), is hovered or focused
* - `loop`: plays forever
* - `once`: plays once when the icon mounts
*/
export type IconTrigger = "hover" | "loop" | "once";
export type AnimatedIconProps = ComponentProps<"span"> & {
animation: IconAnimation;
trigger?: IconTrigger;
/** Length of one cycle in milliseconds. */
duration?: number;
};
const SHAPES = "path, line, polyline, polygon, circle, ellipse, rect";
/**
* Animates any SVG icon passed as its child. Works with lucide-react,
* react-icons, or your own SVGs. The keyframes live in `theme.css`, and
* every animation is switched off under `prefers-reduced-motion`.
*/
export function AnimatedIcon({
animation,
trigger = "hover",
duration,
className,
style,
children,
...props
}: AnimatedIconProps) {
const ref = useRef<HTMLSpanElement>(null);
// "draw" animates stroke-dashoffset from 1 to 0, which needs every shape
// normalized to pathLength="1". Icons can render late (lazy or dynamic
// icons), so re-prime whenever the children change.
useEffect(() => {
const el = ref.current;
if (animation !== "draw" || !el) return;
const prime = () => {
el.querySelectorAll(SHAPES).forEach((shape) => shape.setAttribute("pathLength", "1"));
el.dataset.suiReady = "";
};
prime();
const observer = new MutationObserver(prime);
observer.observe(el, { childList: true, subtree: true });
return () => observer.disconnect();
}, [animation]);
return (
<span
ref={ref}
data-sui-icon={animation}
data-sui-trigger={trigger}
className={cn("inline-flex shrink-0", className)}
style={duration ? ({ ...style, "--sui-icon-duration": `${duration}ms` } as CSSProperties) : style}
{...props}
>
{children}
</span>
);
}