The library is small on purpose. Three pieces do all the work:
AnimatedIcon.svelte- the Svelte component that owns the SVG and the trigger logic.templates.svelte.ts- a flat array of small WAAPI scripts that animate the SVG.- Per-icon wrappers (
Gear.svelte,Heart.svelte, …) - thin files that embed the SVG variants and forward everything else.
There’s no animation library dependency. Everything runs on the browser’s built-in Web Animations API.
Why WAAPI
WAAPI is the browser’s native animation engine - Element.animate(keyframes, options) returns a controllable Animation object. Compared to a JS animation library:
- No install footprint. Lighter bundle, fewer version-pinning concerns.
- Hardware-accelerated. Transforms and opacity run on the compositor thread by default.
- Cancellable, reversible, scrubable. The standard
AnimationAPI works on every template’s output. - Composites with CSS animations. Mix WAAPI-driven icons with CSS-driven UI without conflicts.
The trade-off: WAAPI doesn’t give you a tween DSL. You write keyframes by hand for each template. That’s a feature here - every template is ~10 lines of readable code.
The template model
A template is one record in TEMPLATES:
interface IconTemplate {
id: string;
label: string;
for?: 'line' | 'fill';
run: (svg: SVGSVGElement) => Animation[];
} id- what you pass to thetemplateprop.label- human-readable name.for- optional hint:'line'(needs a stroke),'fill'(suited to solid shapes), or omitted (works on both).run(svg)- the actual animation. Receives the inner<svg>element, returns theAnimation[]it created.
run is called from startAnimation() inside the component. The component tracks the returned animations so it can cancel them later.
What a template looks like
draw - the default - animates each path’s stroke as if a pen were tracing it:
{
id: 'draw',
label: 'Draw on',
for: 'line',
run: (svg) => {
const out: Animation[] = [];
svg.querySelectorAll('path,circle,line,polyline,polygon,ellipse,rect')
.forEach((el, i) => {
el.setAttribute('pathLength', '1');
(el as any).style.strokeDasharray = '1';
(el as any).style.strokeDashoffset = '1';
out.push(
(el as any).animate(
[{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }],
{ duration: 900, delay: i * 70, easing: 'ease-in-out', fill: 'forwards' }
)
);
});
return out;
}
} The shape:
- Walk every animatable shape inside the SVG.
- Normalize each one’s path length to
1(viapathLength="1") sostroke-dasharrayandstroke-dashoffsetare in unit space. - Set
stroke-dashoffset: 1(line is fully “off”). - Animate
stroke-dashoffsetfrom1to0- the line draws on. - Stagger by index (
delay: i * 70) for the cascade feel. - Return every animation so the caller can cancel them.
When stopAnimation() runs, the component calls each Animation.cancel() and then clearProps(svg) to strip the inline styles the template set.
Why one template works on any icon
Most animation templates operate on shapes inside the SVG using relative geometric transformations (such as scale, rotate, opacity, and stroke). Since these transformations are coordinate-independent or utilize relative units (like setting pathLength="1" for stroke dash offsets), the same templates work seamlessly across different icon sets with different viewBox coordinate spaces (e.g., 256 for Phosphor, 24 for Remix/Flowbite/Hero, and 512 for Ion).
This means:
- New icons “just work” the moment they enter the library.
- Adding a template benefits every existing icon.
- The whole library is essentially
~4,000+ SVGs × ~22 templates.
The component layer
AnimatedIcon.svelte is a Svelte 5 component that:
- Renders a fixed-viewBox
<svg>and injects thesvgprop via{@html}. - Binds the inner
<svg>to asvgElstate. - Wires
mouseenter/mouseleavetostartAnimation/stopAnimationwhentrigger="hover". - Runs
startAnimationin a$effectwhentrigger="mount"and the SVG is set. - Runs
startAnimation/stopAnimationin a$effectbased onactivewhentrigger="controlled". The effect’s cleanup callsstopAnimation(), so flippingtriggeraway from'controlled'(or unmounting) cancels any in-flight - including looping - animation cleanly.
The patch layer (loop, speed, easing) is applied after the template runs by mutating each animation’s KeyframeEffect.updateTiming() - no template has to know about them.
Custom templates
Adding a new template means adding a record to TEMPLATES:
{
id: 'wiggle',
label: 'Wiggle',
run: (svg) => {
svg.style.transformOrigin = 'center';
return [
svg.animate(
[{ transform: 'rotate(0)' }, { transform: 'rotate(8deg)' }, { transform: 'rotate(-8deg)' }, { transform: 'rotate(0)' }],
{ duration: 600, easing: 'ease-in-out' }
)
];
}
} Once it’s in the array, template="wiggle" works on every icon and per-icon component. Reach for this when the bundled templates don’t cover a motion you need. See Core Exports and Types for the API surface.
Why templates live in .svelte.ts
templates.svelte.ts (note the extension) tells the Svelte compiler to allow runes inside the file. Currently the templates use plain JS - they’re a flat array of records - but the extension leaves room for any future $state or $derived use without a rename.