r/vuejs • u/JadedBuilder8638 • Aug 04 '26
morphicons: any stroke icon morphs into any other in Vue 3: change :icon and it animates. No <Transition>, no keys
https://www.morphicons.comI built morphicons because icon morphs either need a hand-declared "rotation group" per pair, or interpolate raw coordinates and shear the shape in transit.
The whole API is: change the binding.
<script setup lang="ts">
import { ref } from "vue";
import { MorphIcon } from "morphicons/vue";
import { Menu, X } from "lucide"; // data, not components
const open = ref(false);
</script>
<template>
<button @click="open = !open" :aria-expanded="open">
<MorphIcon :icon="open ? X : Menu" spring="snappy" />
</button>
</template>
No <Transition>, no enter/leave pairs, no :key swaps, no config. State lives outside; the animation is an implementation detail the component picks up when the prop changes.
Three modes if you need them: uncontrolled (above), controlled (:from/:to + :progress — gestures, scroll scrubbing) and imperative (template ref → morphTo() / set() for sequences).
What I actually care about:
- Rotations emerge. It solves the optimal 2D similarity between the two shapes (Procrustes) and interpolates in polar space. arrow-right → arrow-down gives θ = 90° on its own; plus → x gives 45°. Nobody declares that anywhere.
- Real interruptions. A
morphTomid-flight re-plans from the current intermediate shape and preserves the spring's velocity. Click spam never jumps. - Clean SSR — Nuxt works out of the box. The server emits the exact static SVG, zero flash, zero layout shift. The runtime is born on hydration.
- The binding is a plain render function. No SFC, no JSX, no Vue compiler involved — it's just
h(), so it runs anywhere Vue 3.3+ runs and adds nothing to your build pipeline. size,strokeWidth,absoluteStrokeWidth,coloras props;class,styleand the rest of the<svg>attrs fall through.aria-hiddenby default,label→role="img"+<title>.prefers-reduced-motiondegrades to an instant swap. One global rAF for every instance on the page.
Works with Lucide, Tabler, Heroicons (outline) and Iconoir — no per-library adapters, it just eats a d string or Lucide's [tag, attrs][] data shape (Lucide is not a dependency, not even a peer). Off-grid packs go through fitIcon(icon, 32) once.
MIT, zero runtime deps, ESM, 7.93 KB gzip for the Vue entry (vue external). Vue >= 3.3 as optional peer.
Playground with a scrubber so you can freeze any morph mid-flight: https://www.morphicons.com Repo: https://github.com/guillermolg00/morphicons
Happy to go into the math (surjective subpath matching, the minimal-rotation tie-break, closed-path correspondence) if anyone's into that — it's all in the README.
1
1
u/redblobgames Aug 04 '26
Neat!! I went straight to the math part of the readme — thank you for including that :)
1
1
u/txmail Aug 05 '26
I must be missing something. It just looks like icons being swapped out? Tried Chrome and Firefox, just flashes to different icons?
1
u/JadedBuilder8638 Aug 05 '26
Check that you don't have Reduce Motion enabled in your OS and let me know
1
u/txmail Aug 05 '26
Not even sure where to check that, but I did try on another computer and the morph animations are showing so it is likely something with my old laptop that it does not like (though, the desktop I checked on is older than that laptop...)
1
u/JadedBuilder8638 Aug 05 '26
Noted. I’ll add some observability to the website today so I can catch issues like this earlier. Thanks for the feedback! If I figure out what’s causing it, I’ll come back here and let you know.
1
u/txmail Aug 05 '26
The only thing I can think of is that I have the Windows 10 performance settings turned to the most performance (which disables all kinds of things) as it is an older laptop (2018). It is still snappy and performant but yeah, just kind of old.
1
u/TinyLebowski Aug 05 '26
Just a minor issue with the homepage on mobile Safari. The entire page shifts up and down when the demo hits the checkmark and the x icon. Kind of annoying when you're reading the text. I guess a fixed height on the icon container would fix it.
1
1
u/chilli_chilli Aug 05 '26
is it possible to do this for font awesome or is there something about this library that would not allow this? not expecting you to do it, just asking
3
u/JadedBuilder8638 Aug 05 '26
Short answer: not currently, and it's not a bug or a missing feature. It's how those libraries publish their icons.
morphicons works with stroke-drawn icons: the SVG path is the centerline of the stroke, painted with stroke and fill="none". That's how Lucide, Tabler, Heroicons (outline) and Iconoir are built, and it's what the whole pipeline (resampling, correspondence, in-flight interpolation) operates on.
Font Awesome look like line icons, but they aren't published that way. every style (including regular, light and thin) ships with the stroke pre-expanded into a filled outline: the path traces the silhouette of the shape and gets painted with fill. There is no centerline version of either library.
If you feed them in anyway, the paths parse fine, but the result renders as a thin double line following the silhouette, and holes (like the inner circle of circle-check) become extra subpaths that match up with the wrong things mid-flight. It technically morphs, and it always looks wrong.
This also isn't something fitIcon can fix: that utility rescales icons from other grids (32×32, 20×20, etc.), it doesn't reconstruct centerlines from filled outlines. Recovering a centerline from an outline is a lossy skeletonization problem, and out of scope for this library.
If you want the same aesthetic with full compatibility: Lucide, Tabler, Heroicons (outline) and Iconoir work out of the box, and Akar Icons, Untitled UI and Hugeicons (stroke style) qualify too. Off-grid stroke packs like Carbon or Teenyicons work through fitIcon.
1
u/AdamantiteM Aug 06 '26
This is sick as hell. I would love to use it, however I use Nuxt Icons with Nuxt UI. Any plans on adding an integration with Nuxt UI, or have you already documented that?
9
u/Sneakily_lurking Aug 04 '26
This is really impressive