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.
Tune the feel
Attraction and friction apply live, mid-flight. The reveal point takes effect on the next morph.
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());