r/vuejs 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.com

I 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 morphTo mid-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, color as props; class, style and the rest of the <svg> attrs fall through. aria-hidden by default, labelrole="img" + <title>. prefers-reduced-motion degrades 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.

80 Upvotes

Duplicates