Topics

Animated icons

glyphfox animates icons in two ways. Living states are presets such as draw, pop or shake that play on a trigger. Morphs reshape one icon into another. Both work in every package and both turn themselves off for people who ask for reduced motion.

Setup

There is nothing extra to install: animation is part of the shared props. The examples below use glyphfox/react; the props have the same names in Vue, Svelte, Angular and the web component, where they are attributes in kebab-case.

terminal
npm install glyphfox

A living state is a few lines of CSS that travel inside the SVG itself, in a <style> element scoped to a class generated for that icon. That is why the same animation works in a component, in the string from toSvg, in a data URI or in an exported file, with no stylesheet to import.

The seven presets

Each preset is tuned for icons rather than generic elements. draw traces the strokes in the order they were drawn and fades in the fill layer at the end. shake swings from the top, like a bell on a hook. The others move the whole icon around its center.

PresetMotionDurationLoops by default
drawtraces each stroke, one after another1100 msno
popgrows in from 55 percent with a little overshoot520 msno
wigglerotates left and right, settling down720 msno
spinfull turn at a constant speed1000 msyes
pulseshrinks and fades a little, then returns1500 msyes
bouncejumps up, then settles with two smaller bounces900 msno
shakeswings from the top with decreasing amplitude950 msno
Presets.jsx
import { LoaderCircleIcon, SparklesIcon, BellIcon, HeartIcon } from 'glyphfox/react';

export function Presets({ hasNews, liked }) {
  return (
    <>
      <LoaderCircleIcon animate="spin" />
      <SparklesIcon animate="draw" />
      <BellIcon animate="shake" trigger="state" active={hasNews} />
      <HeartIcon animate="pop" trigger="click" variant={liked ? 'solid' : 'outline'} />
    </>
  );
}

duration and delay take milliseconds. loop takes true for forever or a number of runs; spin and pulse loop unless you pass loop={false}. A looping draw alternates direction, so the icon draws itself and then erases itself instead of jumping back.

Triggers

The trigger prop decides when a preset plays:

  • mount, the default, plays when the icon first renders.
  • hover plays while the pointer is over the icon or over its direct parent, so an icon inside a button animates when the button is hovered.
  • click replays on every click on the icon.
  • state plays while active is true. This is the trigger to use for anything your application decides: a new notification, a failed save, a finished upload.
Notifications.jsx
import { BellIcon } from 'glyphfox/react';

export function NotificationsButton({ unread }) {
  return (
    <button type="button" aria-label={`Notifications, ${unread} unread`}>
      <BellIcon animate="shake" trigger="state" active={unread > 0} loop={2} />
    </button>
  );
}

In static markup the triggers map to plain CSS hooks. A state animation plays while the svg element has the class gf-on, so server rendered HTML and strings from toSvg can be driven with one line of script: svg.classList.toggle('gf-on', unread > 0). hover needs no script at all.

Morph one icon into another

The morph engine flattens both icons into strands, matches them with an optimal assignment, resamples them to the same number of points and interpolates. Strands without a partner shrink and fade. Any two icons can morph; ten pairs are tuned by hand so they feel designed rather than computed:

There are three ways to ask for a morph. MorphIcon morphs whenever its name changes. morphTo on any icon morphs toward a target when its trigger fires and back when it stops. Hooks and stores (useMorph in React and Vue, morphStore in Svelte) hand you the frames for your own rendering.

CopyButton.jsx
import { useState } from 'react';
import { MorphIcon, MenuIcon, registerIcons } from 'glyphfox/react';
import { iconCopy, iconCheck, iconX } from 'glyphfox';

// Morph targets are looked up by name: register their data once.
registerIcons([iconCopy, iconCheck, iconX]);

export function CopyButton({ text }) {
  const [copied, setCopied] = useState(false);
  const copy = async () => {
    await navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };
  return (
    <button type="button" onClick={copy}>
      <MorphIcon name={copied ? 'check' : 'copy'} duration={300} />
      {copied ? 'Copied' : 'Copy'}
    </button>
  );
}

export function MenuButton({ open, onToggle }) {
  return (
    <button type="button" aria-expanded={open} aria-label="Menu" onClick={onToggle}>
      <MenuIcon morphTo="x" trigger="state" active={open} />
    </button>
  );
}

Morphs take 450 ms unless you pass duration. With trigger="state" the icon follows active in both directions, which suits toggles such as a menu, a password field (eye to eye-off) or a play button.

The same morphs in Vue, Svelte and the web component

PlayButton.vue
<script setup>
import { ref } from 'vue';
import { MorphIcon, registerIcons } from 'glyphfox/vue';
import { iconPlay, iconPause } from 'glyphfox';

registerIcons([iconPlay, iconPause]);
const playing = ref(false);
</script>

<template>
  <button type="button" :aria-label="playing ? 'Pause' : 'Play'" @click="playing = !playing">
    <MorphIcon :name="playing ? 'pause' : 'play'" />
  </button>
</template>
ThemeToggle.svelte
<script>
  import { Icon, morphStore, registerIcons } from 'glyphfox/svelte';
  import { iconSun, iconMoon } from 'glyphfox';

  registerIcons([iconSun, iconMoon]);
  const icon = morphStore('sun', { duration: 400 });
  let dark = $state(false);
</script>

<button type="button" aria-label="Dark theme" aria-pressed={dark}
  onclick={() => { dark = !dark; icon.set(dark ? 'moon' : 'sun'); }}>
  {#if $icon}<Icon icon={$icon.icon} variant={$icon.variant} />{/if}
</button>
index.html
<script src="https://cdn.jsdelivr.net/npm/glyphfox@1"></script>

<button type="button" id="menu" aria-expanded="false" aria-label="Menu">
  <glyph-fox name="menu" morph duration="300"></glyph-fox>
</button>

<script>
  const button = document.getElementById('menu');
  const icon = button.querySelector('glyph-fox');
  button.addEventListener('click', () => {
    const open = button.getAttribute('aria-expanded') !== 'true';
    button.setAttribute('aria-expanded', String(open));
    icon.setAttribute('name', open ? 'x' : 'menu');
  });
</script>

The morph attribute makes <glyph-fox> animate every change of name. The CDN build already contains every icon, so no registration is needed there.

Morphs without JavaScript

toSvg with morphTo returns an SVG whose paths carry the start shape and switch to the target with a CSS d transition. Browsers without CSS d support cross fade to the exact target instead. The result is a plain string, ready for server templates, static sites or a CMS.

build-icons.js
import { toSvg } from 'glyphfox';

// Morphs while the pointer is over the icon or its direct parent.
const menu = toSvg('menu', { morphTo: 'x', trigger: 'hover', duration: 300 });

// Morphs while the svg has the class gf-on: toggle it from any script.
const play = toSvg('play', { morphTo: 'pause', trigger: 'state' });

The CLI writes the same file: npx glyphfox morph menu x --trigger hover --out menu.svg. Only toSvg resolves a target given as a name; renderToString from glyphfox/core expects icon data for morphTo.

Tailwind CSS animation classes

Icons rendered with the Tailwind CSS plugin are masks, not SVG, so they take whole element animations: gf-spin, gf-pulse, gf-pop, gf-wiggle, gf-bounce and gf-shake, with the same timing as the presets. They work with variants such as hover:gf-wiggle. Draw and morphs need inline SVG.

Reduced motion

Every preset carries a prefers-reduced-motion: reduce rule that turns its animation off, the CSS only morph switches without a transition, and the JavaScript morphs of the components jump straight to the final shape. You do not need to check the setting yourself. People who set it still see the final state: the solid heart, the x of an open menu, the check after a copy.

Reduced motion is not the only reason to keep animation short. Motion draws the eye, so reserve looping animations for things that are in progress, such as a loader-circle that spins while a request runs, and stop them when the work is done. The accessibility guide has more on motion and meaning.

Export animated files

The customizer in the icon browser exports any icon with a preset as an animated SVG, a GIF or a WebM video, using the exact same motion: the GIF and WebM frames are sampled from the preset timing. Use the animated SVG wherever SVG is accepted, and GIF or WebM for slides, documentation tools and chat apps that strip SVG.

Performance

  • Presets other than draw animate transform and opacity only, which browsers run without layout work. draw animates stroke-dashoffset, which repaints the icon on every frame: fine for a few icons, worth avoiding on long lists.
  • Component morphs run on requestAnimationFrame and re-render the icon each frame for the length of the morph. A handful at once is cheap; hundreds starting together are not.
  • The generated class names are hashes of the settings, so identical icons produce identical markup and the server and client output match.

Animation props

These are the props that control motion. Every other shared prop, such as variant or weight, can be combined with them.

PropTypeDefaultDescription
animatedraw, pop, wiggle, spin, pulse, bounce, shakenoneLiving state preset.
triggermount, hover, click, statemountWhen the animation or the morph plays.
activebooleanfalseThe state for trigger state.
durationnumber (ms)per presetAnimation or morph duration.
delaynumber (ms)0Animation or morph delay.
loopboolean, numberfalseRepeat forever or a number of times (spin and pulse loop by default).
morphToname, datanoneMorph target; the trigger decides when it shows.

Pitfalls

Morph targets that render nothing
Morph targets, MorphIcon names and morphStore names are looked up by name. A name whose data was never imported or registered is loaded on demand, and in a Vite production build that load does not happen because Vite leaves the variable dynamic import inside node_modules untouched. The target stays empty. Register every name you morph to with registerIcons. Importing a component such as CheckIcon only for its side effect does not help, because tree shaking removes an import that is never used.
Hover and click outside the icon
A hover preset on inline SVG also responds to the direct parent, through CSS. Everything driven by events does not: trigger="click" and the hover trigger of a morph listen on the icon itself, and glyph-fox only reacts to the pointer on the element itself. Clicking the padding of a button does not reach them. Keep the state in the button and pass it down with trigger="state" and active.
Mount animations that replay
A mount animation plays every time the icon mounts: when a list re-renders with new keys, when a route changes, and on glyph-fox whenever one of its attributes changes. If an icon should animate once, use trigger="state" and decide in your code when it plays.
State animations that never stop
A preset with loop keeps playing while active stays true. Set active back to false when the event is handled, or give loop a number.
Static markup and clicks
In HTML from toSvg nothing listens for clicks. Use trigger: 'state' and toggle the gf-on class from your own handler.

Questions

Do animated icons need JavaScript?

Living states do not: the CSS is inside the SVG, so a server rendered or static icon animates on mount and on hover with no script. The CSS only morph from toSvg also runs without a framework. Component morphs such as MorphIcon use JavaScript to compute the frames.

Can an icon morph and play a preset at the same time?

Yes. animate and morphTo are independent props, so a heart can pop on click while it morphs to the solid style: <HeartIcon animate="pop" morphTo="heart/solid" trigger="click" />.

Icons in this guide