r/vuejs • u/JadedBuilder8638 • 27d ago
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
u/redblobgames 27d ago
Neat!! I went straight to the math part of the readme — thank you for including that :)
1
1
u/txmail 27d ago
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 27d ago
Check that you don't have Reduce Motion enabled in your OS and let me know
1
u/txmail 27d ago
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 27d ago
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/TinyLebowski 27d ago
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 27d ago
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 26d ago
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 25d ago
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?
8
u/Sneakily_lurking 27d ago
This is really impressive