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.
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.
| Preset | Motion | Duration | Loops by default |
|---|---|---|---|
draw | traces each stroke, one after another | 1100 ms | no |
pop | grows in from 55 percent with a little overshoot | 520 ms | no |
wiggle | rotates left and right, settling down | 720 ms | no |
spin | full turn at a constant speed | 1000 ms | yes |
pulse | shrinks and fades a little, then returns | 1500 ms | yes |
bounce | jumps up, then settles with two smaller bounces | 900 ms | no |
shake | swings from the top with decreasing amplitude | 950 ms | no |
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.hoverplays while the pointer is over the icon or over its direct parent, so an icon inside a button animates when the button is hovered.clickreplays on every click on the icon.stateplays whileactiveis true. This is the trigger to use for anything your application decides: a new notification, a failed save, a finished upload.
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:
| From | To |
|---|---|
| menu | x |
| play | pause |
| plus | check |
| sun | moon |
| eye | eye-off |
| bell | bell-ring |
| heart | heart in the solid style |
| copy | check |
| lock | unlock |
| wifi | wifi-off |
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.
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
<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>
<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>
<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.
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
drawanimatetransformandopacityonly, which browsers run without layout work.drawanimatesstroke-dashoffset, which repaints the icon on every frame: fine for a few icons, worth avoiding on long lists. - Component morphs run on
requestAnimationFrameand 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.
| Prop | Type | Default | Description |
|---|---|---|---|
animate | draw, pop, wiggle, spin, pulse, bounce, shake | none | Living state preset. |
trigger | mount, hover, click, state | mount | When the animation or the morph plays. |
active | boolean | false | The state for trigger state. |
duration | number (ms) | per preset | Animation or morph duration. |
delay | number (ms) | 0 | Animation or morph delay. |
loop | boolean, number | false | Repeat forever or a number of times (spin and pulse loop by default). |
morphTo | name, data | none | Morph target; the trigger decides when it shows. |
Pitfalls
- Morph targets that render nothing
- Morph targets,
MorphIconnames andmorphStorenames 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 insidenode_modulesuntouched. The target stays empty. Register every name you morph to withregisterIcons. Importing a component such asCheckIcononly 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, andglyph-foxonly 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 withtrigger="state"andactive. - Mount animations that replay
- A
mountanimation plays every time the icon mounts: when a list re-renders with new keys, when a route changes, and onglyph-foxwhenever one of its attributes changes. If an icon should animate once, usetrigger="state"and decide in your code when it plays. - State animations that never stop
- A preset with
loopkeeps playing whileactivestays true. Setactiveback to false when the event is handled, or giveloopa number. - Static markup and clicks
- In HTML from
toSvgnothing listens for clicks. Usetrigger: 'state'and toggle thegf-onclass 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" />.