@magic-spells / morph-engine

Interfaces that become
each other

A spring-driven blob measures what you clicked, morphs its rect, radius, color, border and shadow into the destination, and hands the bounce to the real element as it lands.

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.

Attraction 0.100
Friction 0.32
Hide attraction 0.180
Hide friction 0.50
Reveal at 0.75
State idle
Progress 0.000

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.

Album B · destinations
01 · Afterglow
02 · Estuary
03 · Solstice
04 · Blue hour
05 · Ember
06 · Canopy
Album A · sources
Group state idle
Live engines 0
Complete count 0
Permanent panel

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.

1 · Install shell
# physics-engine + frame-engine come along as deps
npm install @magic-spells/morph-engine
2 · The target html + css
<!-- 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 */
}
3 · The engine javascript
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());
Release

Ship morph-engine v1.1.0

This surface is a real dialog element. While the spring was flying it stayed closed — the engine measured it invisibly and drove it in normal flow, exactly like any other morph target.

It was promoted with showModal() at the reveal point, while still at opacity 0 — so the top-layer handoff repainted an invisible surface and it faded in already modal: top layer, inert page, focus trapped in here, and Escape runs the reverse morph instead of snapping closed (the cancel event is intercepted). Closing demotes it from the top layer on the exact frame the blob takes over, so there is no seam in either direction.

Tab around — you can't leave. Click outside — the ::backdrop routes it back as a close. This is the choreography a dialog-panel element would package up.

Surface <dialog> · top layer
Focus Trapped
Escape Reverse morph

Delete this workspace?

This removes every board, card, and comment in the workspace. There's no undo — the only thing that reverses here is the morph.

Golden hour · 700 × 460