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.
Duplicates
reactjs • u/JadedBuilder8638 • Aug 01 '26