morphicons

Showcase

Iconify-style pipelines render icons as CSS masks: a span with a mask-image and no path in the DOM. The mask adapter morphs that span as it is, keeping its classes and its paint.

Why this path
You already ship icons as CSS masks: UnoCSS presetIcons, Tailwind icon plugins, plain Iconify. This animates the elements you have instead of asking you to switch to inline SVG.
How it works
svgToIcon turns the markup into icon data. maskTarget flips mask-image between two hidden SVG masks every frame, which keeps Safari repainting.
The tradeoff
Every frame repaints the mask on the main thread: fine for toggles and nav, inline SVG stays leaner for icon-heavy views. Client-only, and stroke icons only: filled sets (Material Symbols) belong in inline SVG.

One element, no path in the DOM

The shape below is a plain span: its geometry is a referenced SVG mask the driver rewrites per frame, and its color is just CSS showing through.

Shape
Element paint

The morph never touches color. A gradient, an image, plain currentColor: whatever paints the element shows through the moving mask.

The whole integration

Two adapter calls around the plain DOM driver. The element is anything with a box: a span from UnoCSS presetIcons, a Tailwind icon plugin, or your own markup.

morphicons/adapters
import { createMorph } from "morphicons/dom";
import { maskTarget, svgToIcon } from "morphicons/adapters";

// what an Iconify pipeline ships: markup, not components
const MENU = svgToIcon(
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 12h16"/><path d="M4 18h16"/><path d="M4 6h16"/></svg>',
);
const X = svgToIcon(
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
);

const target = maskTarget(el); // el: your mask-styled <span>
const morph = createMorph(target, MENU);
morph.morphTo(X, "snappy");

// on unmount: morph.destroy(); target.dispose();