Zum Inhalt springen
UI Kit
Accent

Animierte Icons

Sechzehn Icons, die sich mit Sinn bewegen, wenn du auf sie oder ihren Knopf zeigst. Gleiches Raster und gleicher Strich wie die Kit-Icons.

Eine von 4 Components in Mikroanimationen

6 Variants

Variants

Glocke

Die Glocke schwingt, der Klöppel folgt einen Takt später. Spielt beim Zeigen und beim Tastaturfokus des Knopfs.

Hoch- und Herunterladen

Der Pfeil verlässt die Schale und kommt von der anderen Seite zurück. In einer Ablagefläche läuft er von selbst.

Senden

Der Flieger holt aus, startet nach oben rechts und landet wieder von unten links.

Liken und merken

Umschalter mit gefülltem Zustand. Das Herz schlägt, der Stern dreht ein Fünftel und kommt zur Ruhe.

Alle Icons

Das ganze Set als Knöpfe. Zeig auf eins oder geh mit Tab durch, jedes spielt seine eigene Bewegung.

In einer Liste

Icons, die von selbst spielen, mit langer Pause und versetzt, damit sich die Liste nie auf einmal bewegt.

Details

Was Animierte Icons mitbringt, aus Registry und Quellcode gelesen.

Bereich
Visuals
Variants
6
Version
1.0.0 · seit 1.0.0
Datei
components/ui/animated-icon.tsx
Bringt mit
utilskitmotion-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle AnimatedIconProps aus components/ui/animated-icon.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • namePflicht
    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.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle label.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 242 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Animierte Icons?

Mit aiw add @ai-website/animated-icon. Der Befehl legt die Datei components/ui/animated-icon.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Animierte Icons?

6 Varianten, Glocke, Hoch- und Herunterladen, Senden, Liken und merken, Alle Icons und In einer Liste.

Kann ein KI-Assistent Animierte Icons einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Animierte Icons mit aiw_components und installieren sie mit aiw_add @ai-website/animated-icon.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert