Task board
Click a task — the details panel grows out of the card. Click the backdrop mid-flight and the spring reverses from wherever it is, even during the overshoot bounce. Close it after scrolling and the blob still finds its card.
Dropdown
Same engine, second instance, snappier spring — the trigger grows into its options panel and shrinks back into place.
Modal handoff
This one lands in a real <dialog>. The flight happens in normal flow while the dialog is still closed; at the reveal point — while it's still invisible — it's promoted with showModal() — top layer, inert page, focus trap, real Escape semantics. Closing demotes and reverses on the same frame. Escape mid-flight turns it around wherever it is.
Confirmation dialog
A ~9rem pill blows all the way up into a modal — border-radius 100px → 1.25rem, the widest geometry jump on the page. The pill's 1px edge has nowhere to land: this panel is borderless. Rather than flash toward the text color as the border thins away, the engine holds the edge's own color and collapses its width to zero, so the line just quietly recedes. Same showModal() handoff at the reveal point — this pill lands modal too.
Photo
The defaults dissolve the source clone early and fade the destination in — right for a panel growing out of a button, wrong for a picture, which reads as fade-out, empty box, fade-in. cloneFit: 'scale' scales the frozen clone with the blob instead of freezing its pixel size, and cloneFadeUntil: Infinity never dissolves it, so the photo is visible the whole way. handoff picks how the real image takes over: fade crossfades opaque-over-opaque near the end, hard switches in a single frame at 50%.
Card
A picture with a caption under it. 'scale' would paint that 13px caption at 36px mid-flight and land the picture short; cloneFit: 'reflow' sizes the clone's wrapper to the blob instead, so the picture follows the box and the caption keeps its size — the layout the destination has, all the way in.
Tune the feel
Attraction and friction apply live, mid-flight. The reveal point takes effect on the next morph. Enable asymmetric hide settings for a faster, more damped return; they also take over when a show reverses mid-flight.
Move an album as a group
Six independent engines launch through MorphGroup, staggered 60ms apart — the tiles fly up from the bottom shelf into the empty slots. Reset flies them home with a negative stagger, so the group returns in the opposite order. The manual flight below shows complete(): a one-way morph the engine permanently hands to its target.
Wire it up
Three steps: install, give the target its resting rules, point the engine at a pair of elements. Full API in the README.
# physics-engine + frame-engine come along as deps npm install @magic-spells/morph-engine
<!-- anything clickable can be a source --> <button class="task-card">…</button> <!-- the target rests hidden — with visibility, never opacity --> <aside class="task-panel">…</aside> .task-panel { position: fixed; inset: 0; margin: auto; /* center without transform — the engine owns transform */ width: min(560px, calc(100vw - 3rem)); height: min(540px, calc(100vh - 6rem)); visibility: hidden; /* resting state — no opacity, no transition */ }
import { MorphEngine } from '@magic-spells/morph-engine'; const morph = new MorphEngine({ attraction: 0.1, // higher = faster friction: 0.32, // lower = bouncier }); // the panel grows out of whichever card was clicked card.addEventListener('click', () => { morph.show({ from: card, to: panel }); }); // mid-flight? hide() just reverses the spring closeButton.addEventListener('click', () => morph.hide()); // events for app-level choreography — backdrop, focus, … morph.on('show', () => backdrop.setAttribute('visible', '')); morph.on('hide', () => backdrop.removeAttribute('visible')); morph.on('shown', () => panel.focus());