Skip to content
UI Kit
Accent color

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

6 variants

Variants

Bell

The bell swings, the clapper follows a beat later. Plays on hover and on keyboard focus of the button.

Upload and download

The arrow leaves the tray and comes back from the other side. In a drop zone it runs on its own until you act.

Send

The plane leans back, takes off to the top right and lands again from the bottom left.

Like and save

Toggle buttons with a filled state. The heart beats, the star turns a fifth and settles.

All icons

The whole set as buttons. Point at one or move through them with Tab, each plays its own motion.

In a list

Icons that play on their own, with a long rest, staggered so the list never moves all at once.

Details

What Animated icons brings along, read from the registry and the source.

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

Props

The AnimatedIconProps interface from components/ui/animated-icon.tsx, with the comments from the code.

  • namerequired
    AnimatedIconName

    belluploaddownloadsendheartcheckrefreshsearchmailstartrashcopylockeyecartsparkle

  • sizeoptional
    number

    Size 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.

  • activeoptional
    boolean

    Filled state for heart, star and sparkle, and the accent color for all

  • speedoptional
    number

    1 is the designed tempo

  • strokeWidthoptional
    number
  • labeloptional
    string

    Read 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
components/ui/animated-icon.tsx
'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.

Often searched

Jump to

Copied to the clipboard