Animated icons
Sixteen icons that move with meaning when you point at them or their button. Same grid and stroke as the kit icons.
One of 4 components in Micro animations
Variants
Details
What Animated icons brings along, read from the registry and the source.
- Category
- Micro animations
- Area
- Visuals
- Variants
- 6
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/animated-icon.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AnimatedIconProps interface from components/ui/animated-icon.tsx, with the comments from the code.
namerequiredAnimatedIconNamebelluploaddownloadsendheartcheckrefreshsearchmailstartrashcopylockeyecartsparkle
sizeoptionalnumberSize in px. Default 24.
triggeroptional'hover' | 'loop' | 'in-view'hover: plays when the icon or its parent is hovered or focused (default). loop: plays on its own with a rest. in-view: once when it appears.
activeoptionalbooleanFilled state for heart, star and sparkle, and the accent color for all
speedoptionalnumber1 is the designed tempo
strokeWidthoptionalnumberlabeloptionalstringRead to screen readers. Without it the icon is decorative, which is right inside a labelled button.
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 242 lines
'use client';
import { cn, round } from '@/lib/kit';
import { SCENE_CSS, keyframes, useSceneId, useScenePlay } from '@/components/ui/motion-scene';
/*
* Icons that move with meaning: the bell swings, the arrow leaves the tray,
* the plane takes off, the heart beats. Drawn on the 24 px grid of the kit
* icons (1.6 px stroke, round caps), animated with transform, opacity and
* stroke-dashoffset only. Hover plays them on the icon itself or on its
* parent (a button or link), focus-visible too, so keyboard users see the
* same. A still icon is always complete.
*/
export type AnimatedIconName = 'bell' | 'upload' | 'download' | 'send' | 'heart' | 'check' | 'refresh' | 'search' | 'mail' | 'star' | 'trash' | 'copy' | 'lock' | 'eye' | 'cart' | 'sparkle';
interface Part {
d: string;
/** Which motion this part follows */
m?: string;
/** Filled when active (heart, star) */
fill?: boolean;
origin?: string;
/** Draw on with stroke-dashoffset */
draw?: boolean;
/** Hidden when still, only shows while the motion plays */
hidden?: boolean;
}
interface Def {
parts: Part[];
/** Motions in percent of one play: name -> frames */
motions: Record<string, [number, string][]>;
/** Seconds of one play */
dur: number;
}
const ICONS: Record<AnimatedIconName, Def> = {
bell: {
dur: 1,
parts: [
{ d: 'M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14Z', m: 'swing', origin: '12px 3.5px' },
{ d: 'M10 20.5a2 2 0 0 0 4 0', m: 'clap', origin: '12px 3.5px' },
{ d: 'M12 3.5v2', m: 'swing', origin: '12px 3.5px' },
],
motions: {
swing: [[0, 'transform:rotate(0)'], [15, 'transform:rotate(16deg)'], [32, 'transform:rotate(-13deg)'], [48, 'transform:rotate(9deg)'], [64, 'transform:rotate(-5deg)'], [80, 'transform:rotate(2deg)'], [100, 'transform:rotate(0)']],
clap: [[0, 'transform:rotate(0)'], [20, 'transform:rotate(-12deg)'], [38, 'transform:rotate(16deg)'], [55, 'transform:rotate(-9deg)'], [72, 'transform:rotate(5deg)'], [100, 'transform:rotate(0)']],
},
},
upload: {
dur: 0.9,
parts: [
{ d: 'M4.5 14.5v3a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-3' },
{ d: 'M12 15V4.5M7.5 9 12 4.5 16.5 9', m: 'up' },
],
motions: { up: [[0, 'transform:translateY(0);opacity:1'], [40, 'transform:translateY(-7px);opacity:0'], [41, 'transform:translateY(7px);opacity:0'], [100, 'transform:translateY(0);opacity:1']] },
},
download: {
dur: 0.9,
parts: [
{ d: 'M4.5 14.5v3a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-3' },
{ d: 'M12 4.5V15M7.5 10.5 12 15l4.5-4.5', m: 'down' },
],
motions: { down: [[0, 'transform:translateY(0);opacity:1'], [40, 'transform:translateY(7px);opacity:0'], [41, 'transform:translateY(-7px);opacity:0'], [100, 'transform:translateY(0);opacity:1']] },
},
send: {
dur: 1,
parts: [{ d: 'M20.5 3.5 3.5 10.5l7 3 3 7Zm0 0-10 10', m: 'fly' }],
motions: {
fly: [[0, 'transform:translate(0,0) scale(1);opacity:1'], [12, 'transform:translate(-1.5px,1.5px) scale(.95);opacity:1'], [42, 'transform:translate(12px,-12px) scale(.8);opacity:0'], [43, 'transform:translate(-12px,12px) scale(.8);opacity:0'], [100, 'transform:translate(0,0) scale(1);opacity:1']],
},
},
heart: {
dur: 0.9,
parts: [{ d: 'M12 19.5s-7.5-4.3-7.5-9.6A4.1 4.1 0 0 1 12 7.6a4.1 4.1 0 0 1 7.5 2.3c0 5.3-7.5 9.6-7.5 9.6Z', m: 'beat', fill: true, origin: '12px 13px' }],
motions: { beat: [[0, 'transform:scale(1)'], [18, 'transform:scale(1.22)'], [34, 'transform:scale(.94)'], [52, 'transform:scale(1.1)'], [72, 'transform:scale(.98)'], [100, 'transform:scale(1)']] },
},
check: {
dur: 0.7,
parts: [{ d: 'M5 12.5 10 17.5 19.5 7', draw: true, m: 'draw' }],
motions: { draw: [[0, 'stroke-dashoffset:1'], [100, 'stroke-dashoffset:0']] },
},
refresh: {
dur: 0.9,
parts: [{ d: 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4v4.5H15', m: 'turn', origin: '12px 12px' }],
motions: { turn: [[0, 'transform:rotate(0)'], [100, 'transform:rotate(360deg)']] },
},
search: {
dur: 1.1,
parts: [{ d: 'M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Zm4.6-1.9 4.4 4.4', m: 'scan' }],
motions: { scan: [[0, 'transform:translate(0,0)'], [25, 'transform:translate(-1.5px,-1px)'], [50, 'transform:translate(1px,-2px)'], [75, 'transform:translate(1.5px,.5px)'], [100, 'transform:translate(0,0)']] },
},
mail: {
dur: 1,
parts: [
{ d: 'M4 7.5h16v11H4Z' },
{ d: 'M4.5 8 12 13.5 19.5 8', m: 'flap', origin: '12px 8px' },
{ d: 'M8 11.5V5h8v6.5', m: 'letter', hidden: true },
],
motions: {
flap: [[0, 'transform:scaleY(1)'], [30, 'transform:scaleY(-1)'], [70, 'transform:scaleY(-1)'], [100, 'transform:scaleY(1)']],
letter: [[0, 'transform:translateY(4px);opacity:0'], [30, 'transform:translateY(4px);opacity:0'], [50, 'transform:translateY(-1px);opacity:1'], [70, 'transform:translateY(-1px);opacity:1'], [90, 'transform:translateY(4px);opacity:0'], [100, 'transform:translateY(4px);opacity:0']],
},
},
star: {
dur: 0.9,
parts: [{ d: 'm12 4 2.4 5 5.4.7-4 3.7 1 5.4L12 16.2l-4.8 2.6 1-5.4-4-3.7 5.4-.7Z', m: 'spin', fill: true, origin: '12px 12px' }],
motions: { spin: [[0, 'transform:rotate(0) scale(1)'], [45, 'transform:rotate(72deg) scale(1.2)'], [100, 'transform:rotate(72deg) scale(1)']] },
},
trash: {
dur: 0.9,
parts: [
{ d: 'M6.5 8.5h11l-.8 10.2a1.5 1.5 0 0 1-1.5 1.3H8.8a1.5 1.5 0 0 1-1.5-1.3Z' },
{ d: 'M4.5 6.5h15M9.5 6.5V4.5h5v2', m: 'lid', origin: '19.5px 6.5px' },
{ d: 'M10.5 11.5v5M13.5 11.5v5' },
],
motions: { lid: [[0, 'transform:rotate(0) translateY(0)'], [35, 'transform:rotate(14deg) translateY(-2px)'], [65, 'transform:rotate(14deg) translateY(-2px)'], [100, 'transform:rotate(0) translateY(0)']] },
},
copy: {
dur: 0.8,
parts: [
{ d: 'M8.5 8.5h10v11h-10Z', m: 'front' },
{ d: 'M15.5 8.5V4.5h-10v11h3', m: 'back' },
],
motions: {
front: [[0, 'transform:translate(0,0)'], [45, 'transform:translate(1.5px,1.5px)'], [100, 'transform:translate(0,0)']],
back: [[0, 'transform:translate(0,0)'], [45, 'transform:translate(-1.5px,-1.5px)'], [100, 'transform:translate(0,0)']],
},
},
lock: {
dur: 1,
parts: [
{ d: 'M6 11h12v9H6Z' },
{ d: 'M8.5 11V8a3.5 3.5 0 0 1 7 0v3', m: 'open', origin: '15.5px 11px' },
{ d: 'M12 14.5v2' },
],
motions: { open: [[0, 'transform:translateY(0) rotate(0)'], [30, 'transform:translateY(-2px) rotate(0)'], [50, 'transform:translateY(-2px) rotate(25deg)'], [75, 'transform:translateY(-2px) rotate(25deg)'], [100, 'transform:translateY(0) rotate(0)']] },
},
eye: {
dur: 0.8,
parts: [
{ d: 'M2.5 12s3.5-6.5 9.5-6.5S21.5 12 21.5 12s-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z', m: 'blink', origin: '12px 12px' },
{ d: 'M12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z', m: 'blink', origin: '12px 12px' },
],
motions: { blink: [[0, 'transform:scaleY(1)'], [30, 'transform:scaleY(.08)'], [55, 'transform:scaleY(1)'], [100, 'transform:scaleY(1)']] },
},
cart: {
dur: 0.9,
parts: [
{ d: 'M3.5 4.5h2l2 10.5h10l2-7.5H7', m: 'roll' },
{ d: 'M9 19.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z', m: 'roll' },
{ d: 'M12.5 3v4M10.5 5h4', m: 'add' },
],
motions: {
roll: [[0, 'transform:translateX(0)'], [30, 'transform:translateX(2.5px)'], [55, 'transform:translateX(-1px)'], [100, 'transform:translateX(0)']],
add: [[0, 'transform:translateY(-4px);opacity:0'], [40, 'transform:translateY(1px);opacity:1'], [60, 'transform:translateY(0);opacity:1'], [100, 'transform:translateY(0);opacity:1']],
},
},
sparkle: {
dur: 1.1,
parts: [
{ d: 'M11 3.5c.6 4.2 2.3 5.9 6.5 6.5-4.2.6-5.9 2.3-6.5 6.5-.6-4.2-2.3-5.9-6.5-6.5 4.2-.6 5.9-2.3 6.5-6.5Z', m: 'big', fill: true, origin: '11px 10px' },
{ d: 'M18 15.5c.3 1.8 1 2.5 2.8 2.8-1.8.3-2.5 1-2.8 2.8-.3-1.8-1-2.5-2.8-2.8 1.8-.3 2.5-1 2.8-2.8Z', m: 'small', origin: '18px 18.3px' },
],
motions: {
big: [[0, 'transform:scale(1) rotate(0)'], [35, 'transform:scale(1.15) rotate(15deg)'], [70, 'transform:scale(.95) rotate(0)'], [100, 'transform:scale(1) rotate(0)']],
small: [[0, 'transform:scale(1);opacity:1'], [25, 'transform:scale(0);opacity:0'], [55, 'transform:scale(1.3);opacity:1'], [100, 'transform:scale(1);opacity:1']],
},
},
};
export const ANIMATED_ICON_NAMES = Object.keys(ICONS) as AnimatedIconName[];
export interface AnimatedIconProps {
name: AnimatedIconName;
/** Size in px. Default 24. */
size?: number;
/** hover: plays when the icon or its parent is hovered or focused (default). loop: plays on its own with a rest. in-view: once when it appears. */
trigger?: 'hover' | 'loop' | 'in-view';
/** Filled state for heart, star and sparkle, and the accent color for all */
active?: boolean;
/** 1 is the designed tempo */
speed?: number;
strokeWidth?: number;
/** Read to screen readers. Without it the icon is decorative, which is right inside a labelled button. */
label?: string;
className?: string;
}
/**
* <button className="…"><AnimatedIcon name="bell" /> Notify me</button>
* <AnimatedIcon name="heart" active trigger="in-view" />
*/
export function AnimatedIcon({ name, size = 24, trigger = 'hover', active = false, speed = 1, strokeWidth = 1.6, label, className }: AnimatedIconProps) {
const sid = useSceneId('kvai');
const play = useScenePlay<HTMLSpanElement>(trigger === 'hover' ? 'off' : 'in-view');
const def = ICONS[name];
const sp = Math.max(0.1, speed);
const dur = round(def.dur / sp, 3);
const cycle = round(dur + 1.6 / sp, 3);
const share = dur / cycle;
const css = [
SCENE_CSS,
...Object.entries(def.motions).map(([m, frames]) => keyframes(`${sid}-${m}`, frames)),
...Object.entries(def.motions).map(([m, frames]) => keyframes(`${sid}-${m}-l`, [...frames.map(([p, d]) => [round(p * share, 2), d] as [number, string]), [100, frames[frames.length - 1][1]]])),
...Object.keys(def.motions).map((m) => {
const on = trigger === 'hover'
? `.${sid}:hover .${sid}-${m}, :hover > .${sid} .${sid}-${m}, :focus-visible > .${sid} .${sid}-${m}, [data-icon-host]:hover .${sid} .${sid}-${m}, [data-icon-host]:focus-visible .${sid} .${sid}-${m}`
: `.${sid}:not([data-kv="off"]) .${sid}-${m}`;
const value = trigger === 'loop' ? `${sid}-${m}-l ${cycle}s cubic-bezier(.4,0,.2,1) infinite both` : `${sid}-${m} ${dur}s cubic-bezier(.4,0,.2,1) 1 both`;
return `${on} { animation: ${value}; }`;
}),
`@media (prefers-reduced-motion: reduce) { .${sid} * { animation: none !important; } }`,
].join('\n');
const fillOn = active ? 'var(--primary)' : 'none';
return (
<span
ref={play.ref}
data-kv={trigger === 'hover' ? undefined : play.state}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(sid, 'inline-grid shrink-0 place-items-center', active && 'text-primary', className)}
style={{ width: size, height: size }}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className="overflow-visible">
{def.parts.map((p, i) => (
<path
key={i}
d={p.d}
className={p.m ? `${sid}-${p.m}` : undefined}
fill={p.fill ? fillOn : 'none'}
{...(p.draw ? { pathLength: 1, strokeDasharray: '1 1' } : null)}
style={{ transformOrigin: p.origin ?? '12px 12px', transformBox: 'view-box', transition: 'fill .3s', opacity: p.hidden ? 0 : undefined }}
/>
))}
</svg>
</span>
);
}Frequent questions
How do I install Animated icons?
With aiw add @ai-website/animated-icon. The command puts components/ui/animated-icon.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Animated icons have?
6 variants, Bell, Upload and download, Send, Like and save, All icons and In a list.
Can an AI assistant build with Animated icons?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Animated icons with aiw_components and install it with aiw_add @ai-website/animated-icon.