stream/ui
Browse the library

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/react
usage.tsx
import { Rocket } from "lucide-react";
import { AnimatedIcon } from "@streamui/react";

<AnimatedIcon animation="bounce" trigger="hover">
  <Rocket aria-hidden />
</AnimatedIcon>

API

PropTypeDefaultDescription
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.
durationnumber700Length of one cycle in milliseconds.
childrenReactNode—The SVG icon to animate (lucide-react, react-icons, or your own).
...propsComponentProps<"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>
  );
}