任何图标都可以变形为任何其他图标 - 采用弹簧物理效果的基于笔触的图标具有通用变形功能。
morphicons.com — live playground
Universal morphing for stroke-based icons (Lucide, Tab ler, Heroicons, Iconoir, or your own paths): **any icon morphs into any other** with spring physics. Rotations are never declared by hand — they emerge from the math (2D Procrustes + polar interpolation). Zero runtime dependencies. ```tsx import { MorphIcon } from "morphicons/react"; import { Menu, X } from "lucide"; // data, not components setOpen(o => !o)} aria-expanded={open}> ``` That's the whole thing. No wrappers, no `AnimatePresence`, no keys, no from/to pairs, no configuration. State lives outside; the animation is an implementation detail the component picks up when the prop changes. ## Why The usual icon morphs either interpolate raw coordinates (shapes shrink and shear in transit) or require hand-declaring "rotation groups" per icon pair. morphicons solves the optimal similarity between shapes in closed form and interpolates it in its natural space: if a pair is congruent under rotation, it **rotates**; if not, it morphs in the aligned frame. arrow-right → arrow-down yields θ = 90° on its own, without anyone declaring it. ## Install ```bash bun add morphicons # or npm install / pnpm add ``` ESM only. `react` (>= 18), `vue` (>= 3.3), `svelte` (>= 5) and `react-native` (>= 0.71) + `react-native-svg` (>= 14) are optional peers — only needed for `morphicons/react`, `morphicons/vue`, `morphicons/svelte` and `morphicons/react-native`. `morphicons/element` (the `` custom element) and `morphicons/astro` (its SSR shell) need no peer at all — Astro compiles the shell itself, like every `.astro` component shipped as source. **Icons come from a data package, not a component package.** morphicons consumes icon *data* (an `IconNode` or a raw `d` string). For Lucide that means the vanilla `lucide` package: `import { Menu, X } from "lucide"` gives you `IconNode`s, which is why every snippet below says "data, not components". The framework packages (`lucide-react`, `lucide-vue-next`, `@lucide/svelte`, `lucide-react-native`) export components, and `MorphIcon` can't consume those. If your app already renders static icons with one of them, keep it: the data and component packages coexist by design and both tree-shake cleanly, so you only pay for the icons you import. Just keep their versions aligned, so the icons you morph match the ones you render statically. ## Usage ### React — three modes ```tsx import { MorphIcon, type MorphHandle } from "morphicons/react"; // 1. Uncontrolled (90% of uses): change the prop and morphicons animates // 2. Controlled (gestures, scroll): explicit progress, no spring // 3. Imperative (sequences) const ref = useRef(null); ref.current?.morphTo(Check); // animates ref.current?.set(X); // jumps without animating ``` Drop-in replacement for lucide-react (the props surface: the `icon` prop takes Lucide *data*, not lucide-react components, see [Install](#install)): `size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `className` and the rest of the `` props pass straight through. Correct accessibility by default: `aria-hidden` unless you pass `label` (→ `role="img"` + ``). Clean SSR: the server emits the exact static SVG (zero flash, zero layout shift); the runtime is born on hydration. Morphs play regardless of the OS reduce-motion setting by default; opt into honoring it with `reducedMotion="user"` (see [Reduced motion](#reduced-motion-all-five-bindings)). ### Vue — same three modes ```vue <script setup lang="ts"> import { ref } from "vue"; import { MorphIcon, type MorphHandle } from "morphicons/vue"; import { Menu, X, Check } from "lucide"; // data, not components const open = ref(false); const morph = ref<MorphHandle | null>(null); // morph.value?.morphTo(Check) → animates // morph.value?.set(X) → jumps without animating </script> <template> <MorphIcon :icon="open ? X : Menu" spring="snappy" /> <MorphIcon :from="Menu" :to="X" :progress="dragProgress" /> <MorphIcon ref="morph" :icon="Menu" /> </template> ``` Same surface as the React binding: presentation props (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`; `class`, `style` and the rest of the `<svg>` attrs fall through), the same accessibility defaults (`aria-hidden` unless you pass `label`) and the same clean SSR — works with Nuxt out of the box: the server emits the exact static SVG and the runtime is born on hydration. The binding is a plain render function; no SFC compiler or JSX involved. ### Svelte — same three modes ```svelte <script lang="ts"> import { MorphIcon, type MorphHandle } from "morphicons/svelte"; import { Menu, X, Check } from "lucide"; // data, not components let open = $state(false); let morph = $state<MorphHandle>(); // morph?.morphTo(Check) → animates // morph?.set(X) → jumps without animating </script> <MorphIcon icon={open ? X : Menu} spring="snappy" /> <MorphIcon from={Menu} to={X} progress={dragProgress} /> <MorphIcon bind:this={morph} icon={Menu} /> ``` Same surface again: presentation props, `class`/`style`/rest-attr fall-through — fully typed via `svelte/elements`, so SVG attrs, events and ARIA autocomplete and typos fail the build, like lucide-svelte — the same accessibility defaults and the same clean SSR — works with SvelteKit out of the box. Svelte 5 (runes); the component ships as `.svelte` source and your bundler compiles it via the `svelte` export condition, like every Svelte library. If you followed Lucide's official Svelte guide you already have `@lucide/svelte`: keep it for your static icons; `MorphIcon` takes the data exports from the vanilla `lucide` package (see [Install](#install)). ### React Native — same three modes ```tsx import { MorphIcon, type MorphHandle } from "morphicons/react-native"; import { Menu, X, Check } from "lucide"; // data, not components // 1. Uncontrolled (90% of uses): change the prop and morphicons animates <MorphIcon icon={open ? X : Menu} spring="snappy" /> // 2. Controlled (gestures, scroll): explicit progress, no spring <MorphIcon from={Menu} to={X} progress={dragProgress} /> // 3. Imperative (sequences) const ref = useRef<MorphHandle>(null); <MorphIcon ref={ref} icon={Menu} /> ref.current?.morphTo(Check); // animates ref.current?.set(X); // jumps without animating ``` The DOM-free core beyond the browser: the dom driver is reused verbatim as the engine (React Native has a global `requestAnimationFrame`, and `PathEl` is structural), so the whole platform difference is a shim that forwards the per-frame `d` write to `Path.setNativeProps` of react-native-svg — outside the React render, exactly like the web mutation. On the New Architecture that write is not the last word, because every Fabric commit rebuilds the native path from the props React declares: the binding therefore publishes the driver's live `d` on each render, and re-applies it in a layout effect when a frame landed in between, so an unrelated re-render (a color or selection change, at rest or mid-flight) can no longer restore the icon the component mounted with. Same surface as the React binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, plus the native `Svg` props: `testID`, touch handlers…), same accessibility defaults (`aria-hidden` unless you pass `label` → `role="img"` + `aria-label`). With `reducedMotion="user"`, the OS setting comes from `AccessibilityInfo` (best-effort: the query is async; a `reduceMotionChanged` subscription keeps it exact from then on). Requires Metro with package `exports` resolution — default since React Native 0.79; on older versions enable `unstable_enablePackageExports`. ### Astro — same three modes, zero framework runtime ``` … ``` The server emits the exact static SVG with the pure core (zero flash, zero layout shift, works with static output and any SSR adapter) and hydration is just custom-element upgrade: the shell defines `<morph-icon>` once per page and the element adopts the server markup verbatim — zero `d` writes during upgrade, pinned by instrumented tests. No framework runtime ships — the only client bytes are `morphicons/element`. A controlled pair of `d` strings survives upgrade as attributes, so assigning `progress` from a script scrubs the server-frozen pair; `IconNode` pairs are server-only (attributes can't carry them) — control those by assigning the element's properties. Same presentation props as every binding (`size`, `strokeWidth`, `absoluteStrokeWidth`, `color`, `label`); rest attrs (`id`, `class`, `data-*`…) land on the `<morph-icon>` element, because grabbing it from a client script is the interaction model here. React/Vue/Svelte islands keep working for icons that live inside one — this entry is for the pages that don't need an island at all. ### Web component — `<morph-icon>` anywhere HTML reaches The Astro shell is a thin SSR layer over `morphicons/element`, which stands on its own in plain HTML, HTMX, Rails, or any server-rendered stack: ```html <script type="module"> import { defineMorphIcon } from "morphicons/element"; defineMorphIcon(); // idempotent; custom tag: defineMorphIcon("my-icon") </script> <morph-icon icon="M4 6h16M4 12h16M4 18h16" label="Menu"></morph-icon> ``` The element is the fifth binding, not a second implementation: it wires attributes and properties to the same shared controller, passes the same mirrored mount suite, and exposes the same three modes — `icon` (uncontrolled), `from`/`to`/`progress` (controlled), `morphTo`/`set` methods (imperative, the element IS the handle). Attributes carry strings (`d` paths, preset names); properties accept everything (`IconNode`s, custom springs). If the element already contains a server-rendered `<svg><path>`, it adopts it verbatim and treats its `d` as the at-rest icon — SSR bytes are never rewritten at rest. ### Lifecycle contract (all five bindings) The five components share one contract, pinned by mirrored client-mount tests: - **Lazy driver.** Mounting without any icon is fine — SSR emits `<path d="">` and the driver is born with the FIRST icon that shows up, whether a late `icon` prop (data that loads async), a late `from`/`to` pair, or an imperative `set`/`morphTo`. The first icon paints without animating; `morphTo` before the driver exists behaves as `set` (there is nothing to fly from). - **Controlled wins.** While `from` AND `to` are both present, the pair owns the path: `icon` changes are ignored, no spring fires. Drop the pair and the current `icon` takes over (animated). Mixing the modes is not an error — the precedence is just explicit. - **Clean re-entry.** Any exit from controlled mode (an imperative call or an icon takeover) invalidates the frozen pair, so returning to the same `from`/`to` re-bases on `from` and renders exactly like a clean mount at that `progress`. ### Reduced motion (all five bindings) Icon morphs are small, short, communicative micro-transitions: the kind of motion the reduce-motion guidance considers generally acceptable, unlike parallax or full-screen movement. Auto-degrading them made the library look broken to every user with the OS setting on, so since 1.4.2 they play by default and the policy is an explicit prop (the same default Motion, formerly Framer Motion, ships): ```tsx <MorphIcon icon={open ? X : Menu} reducedMotion="user暂无开放 Issues,或尚未同步最近议题。