Morph
morph turns one element into another: a card into a dialog, a thumbnail into a full view. Both elements stay mounted. The incoming one starts on the box of the outgoing one and settles on its own, the outgoing one travels onto the box of the incoming one, and their content crossfades with a blur.
Reversing is the same call with the arguments swapped. The values that are moving are retargeted, so the reversal continues from the previous visual state with the same velocity instead of restarting from rest.
Functions
Section titled “Functions”Morphs from into to with a spring, interrupting and reversing any morph already running on either element.
export function morph(from: HTMLElement, to: HTMLElement, options: MorphOptions = {}): MorphControls| Parameter | Type | Default | Description |
|---|---|---|---|
from |
HTMLElement |
none | The element the morph starts from, usually the one that is always visible. |
to |
HTMLElement |
none | The element the morph ends on. |
options |
MorphOptions |
{} |
Spring, scheduler, reduced motion, correction, radius, crossfade and blur. |
Returns MorphControls: finished resolves to a boolean and stop() freezes both elements.
Preconditions (not enforced)
- Both elements are visible and laid out at their final boxes when
morphis called. A hidden element reports a zero box (a finite one), so the morph runs with wrong geometry rather than throwing. Unhide the target first. - All the layout assumptions apply to both elements: not rotated,
scaleof1, centeredtransform-origin.
Behavior
- Geometry.
tostarts on the box offromand settles at the identity;fromtravels onto the box ofto. Both are moved with the JS driver. A morph ownsx,y,scaleXandscaleYof both elements,opacityunlesscrossfadeisfalse, and the blur of the corrected children. - Crossfade. With perceptual options the incoming fade uses
duration / 2and the outgoing fadeduration, both withbounce: 0, so the combined opacity never dips below 0.85 mid-morph (measured at 0.893 or better in a real browser). With physical options both fades use that same spring; it may overshoot, and the browser clamps opacity. YourrestDeltaandrestSpeedare not forwarded to the fades. - Reversal.
morph(to, from)while one runs retargets the same values, keeping position and velocity. A fresh incoming element starts from its current values, not from the identity. - Finishing. A full open and close cycle ends clean: the card at the identity, and the dialog resting on the card box.
- Reduced motion. The spatial values jump (boxes, radius and corrections are applied), the crossfade and blur still animate, and
finishedresolvestrue. - Server rendering. Not applicable: it needs laid-out elements.
Edge cases
- Throws
TypeError("morph() needs two different elements")whenfrom === to. - Throws a
RangeErrorfor invalid spring options,radiusorblur, andRangeError("morph() measured a non-finite layout box")for a non-finite box. All of these are thrown before anything is touched, and a failed morph never cuts the morph that is running. - A zero-size box cannot be scaled to, so that axis keeps scale
1and the inverse scale stays finite.
Open the card, then press Escape or Close while it is still opening. The morph reverses from where it is.
City Power
$84.20
- Usage
- 312 kWh
- Period
- Jun 1 to Jun 30
- Due
- Jul 5
Press Escape or Close, even while it is still opening. It reverses from where it is.
Example
import { morph } from "@damped/core";
const card = document.querySelector<HTMLElement>(".card");const dialog = document.querySelector<HTMLElement>(".dialog");
if (card !== null && dialog !== null) { const options = { duration: 0.5, bounce: 0.1, radius: 16 } as const;
dialog.hidden = false; // visible and laid out at its final box before morph() measures it const opening = morph(card, dialog, options);
// Reverse at any point: the same call with the arguments swapped. const closing = morph(dialog, card, options);
console.log(await opening.finished, await closing.finished); // false, true}See also: MorphOptions, MorphControls, useMorph, layout, Layout and morph, Interruption and reversal, Demos
MorphOptions
Section titled “MorphOptions”The options of morph: everything in LayoutOptions plus the crossfade and the content blur.
export type MorphOptions = LayoutOptions & { /** Fade `to` in and `from` out. Default true. */ crossfade?: boolean; /** Blur radius in px applied to the content children during the morph (incoming: blur → 0, outgoing: 0 → blur). Default 8; 0 disables. */ blur?: number;};| Option | Type | Default | Description |
|---|---|---|---|
| spring options | SpringOptions |
duration: 0.5, bounce: 0.15 |
Either form. |
scheduler, reducedMotion |
as in LayoutOptions |
the shared frame, "user" |
|
correct |
readonly HTMLElement[] | "children" |
"children" |
Differs from layout, where it is off. An explicit list is split between the elements that contain each entry; [] leaves the children alone. |
radius |
number |
none | Corrected on both elements, and written at rest on the one that ends at the identity. |
crossfade |
boolean |
true |
Animates to from opacity 0 to 1 and from from 1 to 0. |
blur |
number |
8 |
Blur radius in px on the content children: incoming from blur to 0, outgoing from 0 to blur. Finite and >= 0, otherwise a RangeError. 0 writes no filter at all. |
Behavior
- The shells never get a filter, so their edges stay crisp; only the corrected children blur.
bluris independent ofcrossfade.
Example
import { morph, type MorphOptions } from "@damped/core";
const options: MorphOptions = { duration: 0.5, bounce: 0.1, radius: 16, blur: 6, crossfade: true };
const thumbnail = document.querySelector<HTMLElement>(".thumbnail");const full = document.querySelector<HTMLElement>(".full-view");if (thumbnail !== null && full !== null) { full.hidden = false; void morph(thumbnail, full, options);}See also: morph, LayoutOptions, MorphHandle, Layout and morph
MorphControls
Section titled “MorphControls”What morph returns.
export interface MorphControls { /** true if this morph settled; false if a later morph/stop on either element superseded it. Never rejects. */ readonly finished: Promise<boolean>; stop(): void;}| Member | Type | Description |
|---|---|---|
finished |
Promise<boolean> |
true only if this morph settled. false when a newer morph that touches either element superseded it, or stop() was called while it ran. Never rejects. |
stop() |
() => void |
Freezes both elements and keeps the applied corrections. |
Behavior
stop()after the morph settled changes nothing, andstop()on a superseded morph does not stop the newer one.stop()before the first frame still reports the morph as stopped (finishedresolvesfalse).- Unlike
AnimationControls,finishedtells you whether it completed, so you can run follow-up work only when it did, for example to move focus after a close.
Example
import { morph } from "@damped/core";
const card = document.querySelector<HTMLElement>(".card");const dialog = document.querySelector<HTMLElement>(".dialog");
if (card !== null && dialog !== null) { // The dialog is open here, laid out on its own box. const closing = morph(dialog, card, { duration: 0.4 });
const settled = await closing.finished; if (settled) { dialog.hidden = true; // only when no newer morph cut this one card.focus(); }}See also: morph, MorphOptions, MorphHandle, AnimationControls