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.
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
npm install morphiconsESM 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.
| entry | what it gives you |
|---|---|
morphicons/react | <MorphIcon>, also vue, svelte and react-native |
morphicons/astro | an Astro component that renders on the server |
morphicons/element | the <morph-icon> custom element, for plain HTML |
morphicons/dom | createMorph(), no framework |
morphicons/adapters | svgToIcon, maskTarget, canvasTarget |
morphicons/region | the fill code, normally loaded on demand |
morphicons | the 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:
// 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, absoluteStrokeWidth | as 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 |
label | accessible 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
morphTobefore it acts asset. - While
fromandtoare both set, they win overicon. Drop the pair andicontakes 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
<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
<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
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
---
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
<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
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:
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:
import { setRegionLoader } from "morphicons/dom";
import * as region from "morphicons/region";
setRegionLoader(async () => region); // bundle the fill code, no networkOr 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:
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 fillfitIcon 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 (UnoCSSpresetIcons, Tailwind icon plugins), keeping your classes.canvasTarget(canvas): draws the morph into a canvas, anOffscreenCanvasor a 2D context. For WebGL textures, video, workers or an animated favicon.
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();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, everymorphTojumps. React Native readsAccessibilityInfo."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.
icon A ─┐
├→ normalize → resample → match → align → plan → interpolate(t) → d
icon B ─┘ (cached per pair)- Normalize. Every SVG shape becomes cubic Béziers: lines, quadratics, circles and arcs.
- 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.
- 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.
- Align. The rotation θ and scale σ that best map A onto B have a closed form in 2D (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.
- Interpolate. Rotation, scale and shape are interpolated separately, so a rotation never collapses through the middle the way a plain coordinate blend does:
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- 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 exactd.
| preset | feel |
|---|---|
smooth | k 170, c 26: no overshoot |
snappy | k 420, c 30: fast, a slight overshoot (default) |
bouncy | k 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.
| entry | gzip |
|---|---|
morphicons | 6.7 KB |
morphicons/dom | 9.3 KB |
react | 10.2 KB |
vue | 10.3 KB |
svelte | 9.9 KB |
element | 11.1 KB |
react-native | 15.9 KB (Metro inlines the fill code) |
fill code | +5.4 KB, on first use |
adapters | 0.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.