morphicons

Docs

Morph any SVG icon into any other, stroked or filled, with spring physics. Change the prop and the icon morphs: no wrappers, keys or per-pair setup.

tsx
import { MorphIcon } from "morphicons/react";
import { Menu, X } from "lucide"; // icon data, not components

<button onClick={() => setOpen((o) => !o)} aria-expanded={open}>
  <MorphIcon icon={open ? X : Menu} />
</button>

Install

terminal
npm install morphicons

ESM only, zero runtime dependencies. Framework peers are optional: you only need react (18+), vue (3.3+), svelte (5+) or react-native (0.71+) with react-native-svg (14+) for the entry you import. The element and Astro entries need none.

entrywhat it gives you
morphicons/react<MorphIcon>, also vue, svelte and react-native
morphicons/astroan Astro component that renders on the server
morphicons/elementthe <morph-icon> custom element, for plain HTML
morphicons/domcreateMorph(), no framework
morphicons/adapterssvgToIcon, maskTarget, canvasTarget
morphicons/regionthe fill code, normally loaded on demand
morphiconsthe pure core, no DOM

Icons are data

morphicons takes icon data: an IconNode (Lucide’s format, [tag, attrs][]) or a raw d string. For Lucide, import from lucide, not lucide-react: the framework packages export components, which MorphIcon can’t read. If your app already uses them for static icons, keep them; both tree-shake, so you only pay for what you import. Keep their versions aligned.

Usage

Three modes, the same in every binding:

tsx
// 1. Uncontrolled: change the prop and it animates
<MorphIcon icon={open ? X : Menu} />

// 2. Controlled: you drive the progress (gestures, scroll), no spring
<MorphIcon from={Menu} to={X} progress={0.4} />

// 3. Imperative: sequences
const ref = useRef<MorphHandle>(null);
<MorphIcon ref={ref} icon={Menu} />
ref.current?.morphTo(Check); // animates
ref.current?.set(X);         // jumps, no animation
prop
size, color, strokeWidth, absoluteStrokeWidthas in lucide-react; other <svg> attributes pass through
spring"snappy" (default), "smooth", "bouncy" or { stiffness, damping }
reducedMotion"never" (default), "user" or "always", see Reduced motion
labelaccessible name: role="img" plus a <title>. Without it the icon is aria-hidden

SSR renders the final SVG, computed once on the server, so nothing flashes or shifts on hydration. A morphTo mid-flight starts from the shape on screen and keeps the spring’s velocity: clicking fast never jumps. Every icon on the page shares one requestAnimationFrame.

Lifecycle

  • Mounting without an icon is fine (data that loads later). The first icon appears without animating, and a morphTo before it acts as set.
  • While from and to are both set, they win over icon. Drop the pair and icon takes over, animated.
  • Leaving controlled mode and coming back renders like a fresh mount.

Frameworks

Five bindings with the same props, modes and lifecycle. React is above; the others look like this.

Vue 3

vue
<script setup lang="ts">
import { ref } from "vue";
import { MorphIcon, type MorphHandle } from "morphicons/vue";
import { Menu, X } from "lucide";

const open = ref(false);
const morph = ref<MorphHandle | null>(null); // morph.value?.morphTo(X)
</script>

<template>
  <MorphIcon :icon="open ? X : Menu" />
  <MorphIcon ref="morph" :icon="Menu" />
</template>

A plain render function, no SFC compiler involved. Works with Nuxt SSR.

Svelte 5

svelte
<script lang="ts">
  import { MorphIcon, type MorphHandle } from "morphicons/svelte";
  import { Menu, X } from "lucide";

  let open = $state(false);
  let morph = $state<MorphHandle>(); // morph?.morphTo(X)
</script>

<MorphIcon icon={open ? X : Menu} />
<MorphIcon bind:this={morph} icon={Menu} />

Ships as .svelte source that your bundler compiles. Works with SvelteKit SSR. Attributes are typed through svelte/elements.

React Native

tsx
import { MorphIcon } from "morphicons/react-native";
import { Menu, X } from "lucide";

<MorphIcon icon={open ? X : Menu} color="#111" />

The React API over react-native-svg, on the same driver as the web. Needs Metro with package exports, the default since React Native 0.79. With label it sets aria-label.

Astro

astro
---
import MorphIcon from "morphicons/astro";
import { Menu } from "lucide";
---

<MorphIcon icon={Menu} label="Menu" id="menu-icon" />

<script>
  import { X } from "lucide";
  import type { MorphIconElement } from "morphicons/element";

  const el = document.querySelector<MorphIconElement>("#menu-icon")!;
  el.morphTo(X);
</script>

The server renders the SVG and the browser upgrades it to a <morph-icon>, with no framework runtime. Control it from a script through the element: el.icon = X animates, el.morphTo and el.set work like a ref, and from, to and progress scrub.

Plain HTML

html
<script type="module">
  import { defineMorphIcon } from "morphicons/element";
  defineMorphIcon(); // idempotent; a custom tag: defineMorphIcon("my-icon")
</script>

<!-- attributes take d strings, properties take any icon -->
<morph-icon icon="M4 6h16M4 12h16M4 18h16" label="Menu"></morph-icon>

Works in HTMX, Rails or any server-rendered stack. The element is its own handle. If it already contains a server-rendered <svg><path>, it adopts it as is.

Without a framework

ts
import { createMorph } from "morphicons/dom";

const m = createMorph(pathEl, Menu); // an SVG <path>
m.morphTo(X, "snappy");              // a preset or { stiffness, damping }
m.set(Check);                        // jump, no animation
m.seek(X, 0.4);                      // frozen at t: scrubbing, gestures
m.progress = 0.4;                    // seek on the active target
m.reducedMotion = "user";            // live policy
m.destroy();

createMorph(el, icon, options) takes any object with setAttribute. Options: reducedMotion and strokeWidth (the width a stroked icon is outlined with when it morphs into a filled one, default 2). The pure core works with no DOM at all:

ts
import { allocOutputs, buildPlan, interpPolar, resampleIcon, serialize } from "morphicons";

const plan = buildPlan(resampleIcon(Menu), resampleIcon(X)); // cache it
const out = allocOutputs(plan);
interpPolar(plan, 0.5, out);                                 // t in [0, 1]
const d = serialize(out, plan.items.map((it) => it.closed));

Filled icons

When either icon is filled, both morph as filled shapes, so a stroked heart can turn into a solid one. An icon counts as filled when one of its elements has a fill other than none; a raw d string is always stroked. svgToIcon keeps the fill of the markup it reads.

The code for this (5.4 KB gzip) loads the first time a filled icon shows up; stroke-only apps never download it. Until then a filled icon still renders filled, and a morph waits at most 100 ms for the chunk before flying a simpler fallback. To bundle it instead:

ts
import { setRegionLoader } from "morphicons/dom";
import * as region from "morphicons/region";

setRegionLoader(async () => region); // bundle the fill code, no network

Or call preloadRegion() from morphicons/dom to fetch it early. For long lists of filled icons on slow devices, run toRegion(icon) from morphicons/region at build time: the driver uses its output as is.

Icon libraries

There is no per-library setup. Any set on a 24×24 grid works as is: Lucide, Tabler, Heroicons, Iconoir, Phosphor, Material Symbols, Remix Icon, MingCute and the shadcn registry. Both icons of a morph must share a grid, so mixing libraries is fine.

For other grids (Heroicons mini on 20, Bootstrap on 16, Carbon on 32), re-grid once, at module scope:

ts
import { fitIcon } from "morphicons";
import { svgToIcon } from "morphicons/adapters";

const search = fitIcon(carbonSearch, 32);  // a 32×32 path onto the 24 grid
const star = svgToIcon(markup);            // reads the viewBox and the fill

fitIcon returns a d string, which is a stroked icon. For filled icons on another grid, use svgToIcon: it reads the viewBox and keeps the paint. Supported elements are path, line, circle, ellipse, rect, polyline and polygon.

Format adapters

morphicons/adapters converts other formats in and out. Each adapter tree-shakes on its own, so you pay for the one you import.

  • svgToIcon(markup): SVG markup to an icon. An Iconify body, a full <svg> or a pasted <path>. Transforms are baked in, <defs> and masks are dropped.
  • maskTarget(el): morphs CSS-mask icons in place (UnoCSS presetIcons, Tailwind icon plugins), keeping your classes.
  • canvasTarget(canvas): draws the morph into a canvas, an OffscreenCanvas or a 2D context. For WebGL textures, video, workers or an animated favicon.
ts
import { maskTarget, svgToIcon } from "morphicons/adapters";
import { createMorph } from "morphicons/dom";

const MENU = svgToIcon(menuBody); // parse once, at module scope
const X = svgToIcon(xBody);

const target = maskTarget(span);  // <span class="i-lucide-menu size-5">
const m = createMorph(target, MENU);
m.morphTo(X);
// on unmount: m.destroy(); target.dispose();
ts
import { canvasTarget } from "morphicons/adapters";
import { createMorph } from "morphicons/dom";

const m = createMorph(canvasTarget(canvas, { color: "#e8eaed" }), PLAY);
m.morphTo(PAUSE);

maskTarget and canvasTarget draw strokes only for now. A host with its own render loop (Konva, Chart.js, a game) should give the icon a small canvas of its own and composite it, using the onWrite option as the repaint signal. See the mask and canvas demos.

Reduced motion

Icon morphs are short, small transitions, so they play by default even with the OS reduce-motion setting on. The policy is explicit:

  • "never" (default): always animate.
  • "user": follow the OS setting; while it is on, every morphTo jumps. React Native reads AccessibilityInfo.
  • "always": never animate (tests, screenshots).

Apps that must follow the user’s preference should pass reducedMotion="user" everywhere.

How it works

The core is pure functions: icon data in, d strings out. Planning a pair takes well under a millisecond and is cached; each frame allocates only the d string.

pipeline
icon A ─┐
        ├→ normalize → resample → match → align → plan → interpolate(t) → d
icon B ─┘                                        (cached per pair)
  1. Normalize. Every SVG shape becomes cubic Béziers: lines, quadratics, circles and arcs.
  2. Resample. Each subpath becomes 64 points evenly spaced by arc length, so point i of A maps to point i of B. Corners are exact sample points, so they stay sharp at rest.
  3. Match. Both directions are tried and, for closed paths, every starting point. Subpaths pair by position and length; when the counts differ, the extra pieces split off the nearest one instead of appearing from nothing.
  4. Align. The rotation θ and scale σ that best map A onto B have a closed form in 2D (Procrustes):
procrustes
θ* = atan2(Σ aₓbᵧ − aᵧbₓ,  Σ aₓbₓ + aᵧbᵧ)
σ* = [cos θ*·(Σ aₓbₓ + aᵧbᵧ) + sin θ*·(Σ aₓbᵧ − aᵧbₓ)] / Σ‖aᵢ‖²

The error left over says how different the shapes are. Near zero means B is A rotated, so arrow-right to arrow-down turns 90° without anyone declaring it. Among equal fits the shortest turn wins (error + 0.05·|θ|/π), which is why a hamburger folds into an X at 45° and never at 135°. When the whole icon is one rotated shape, all its parts turn together, as a rigid block.

  1. Interpolate. Rotation, scale and shape are interpolated separately, so a rotation never collapses through the middle the way a plain coordinate blend does:
interpolate
P(t) = c(t) + σ*ᵗ · R(t·θ*) · [(1−t)·aᵢ + t·b̃ᵢ]

c(t)   translation, a plain lerp
t·θ*   angle, linear (θ* already takes the short way)
σ*ᵗ    scale, log-linear
b̃ᵢ     B brought into A's frame
  1. Spring. ẍ = k·(1 − x) − c·ẋ drives t. Past 1 the formula extrapolates, so the overshoot rotates and scales a little too. On settle the driver writes the target’s exact d.
presetfeel
smoothk 170, c 26: no overshoot
snappyk 420, c 30: fast, a slight overshoot (default)
bouncyk 300, c 14: playful

A filled icon has no line to follow, so when either side is filled both are reduced to their visible surface (a distance field traced into closed contours, holes wound the other way) and go through the same steps. The full write-up, with the decisions behind it, is in docs/how-it-works.md and the ADRs.

Size

gzip, with gates enforced in CI so it can’t grow unnoticed.

entrygzip
morphicons6.7 KB
morphicons/dom9.3 KB
react10.2 KB
vue10.3 KB
svelte9.9 KB
element11.1 KB
react-native15.9 KB (Metro inlines the fill code)
fill code+5.4 KB, on first use
adapters0.5 to 4.3 KB each

Credits

The icons in the playground belong to their authors: Lucide (ISC), Heroicons, Tabler, Phosphor, Iconoir and Bootstrap Icons (MIT), Material Symbols, Material Design Icons, Remix Icon and MingCute (Apache 2.0). morphicons is MIT, made by guillermolg.com.