@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.

Tune the feel

Attraction and friction apply live, mid-flight. The reveal point takes effect on the next morph.

Attraction 0.100
Friction 0.32
Reveal at 0.75
State idle
Progress 0.000

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.