Skip to main content

Morph – transitions as nodes

A morph node describes which element becomes which, and on what interaction. Everything else in N says how an element looks; this says what replaces what.

let nodes = [
{ op: "morph", from: "topnav", to: { About: "about", Contact: "contact" },
effect: "t-vhs", duration: 900, back: true },
];

The node holds only ids. It never holds a component, so the whole transition stays serialisable data alongside the rest of the pair.

Naming the ends

from is the element that morphs. to takes two forms:

to: { About: "about", Contact: "contact" }   // by LABEL – the link text decides
to: ["about", "contact"] // by POSITION – link i goes to i

Prefer labels. A responsive navbar renders a different set of links at different breakpoints, so the n-th link is not the same link at two window widths, and a positional mapping quietly sends the wrong link to the wrong destination on one of them.

Ids may be written bare or in selector form – "work" and "#work" mean the same element.

A chain: one node, a whole graph

Carry chain instead of a single from/to and the same node describes a navigation graph:

{ op: "morph", effect: "t-vhs", duration: 620, back: true,
chain: [
{ from: "home", to: { Work: "work", Contact: "contact" } },
{ from: "work", to: { Aurora: "aurora" }, effect: "t-split" },
{ from: "aurora", to: { Contact: "contact" }, effect: "t-bloom" },
] }

The entries are edges, not keyframes. Entry two does not happen after entry one; it is reachable from the state entry one lands on. So they carry no ordinal – array order is already order, and a number would claim a sequence the graph does not have.

Settings written on the node are defaults every edge inherits and any edge may override: above, each hop runs a different effect chain while sharing one duration. A landed state becomes a source, which is what separates a chain from several unrelated morphs sharing a page.

chain wins outright: node-level from/to are not read beside it.

A complete page you can paste

Everything above is a fragment. This is the whole thing: one file, no build step, no install. Save it as index.html, open it, and the nav transitions.

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Morph nav</title></head>
<body>
<div id="mount"></div>

<script type="module">
import { Des } from "https://www.unpkg.com/nodality@1.2.8/dist/index.esm.js";

// Each destination is an ordinary element with an id. The id is what the
// morph node points at, and the back button is what unwinds the path.
const view = (id, heading, body) => ({
id, type: "wrap", background: "#eef2f6", radius: "14px",
children: [
{ id: id + "-h", type: "h3", text: heading },
{ id: id + "-p", type: "p", text: body },
{ id: id + "-back", type: "button", text: "\u2190 back" },
],
});

const E = [
{ id: "topnav", type: "nav" },
view("about", "About", "Who we are."),
view("offer", "Services", "What we do."),
view("contact", "Contact", "How to reach us."),
];

const N = [
{ op: "morph", from: "topnav",
to: { About: "about", Services: "offer", Contact: "contact" },
effect: "t-vhs", duration: 620, back: true },
];

new Des().nodes(N).add(E).set({ mount: "#mount", code: false });
</script>
</body>
</html>

Two things in there are easy to get wrong, and both cost a silent no-op rather than an error.

The keys of to are the labels the nav actually renders, not names you choose. { type: "nav" } renders About, Services and Contact, so those are the keys. Change the nav and the keys change with it; use a label that is not on screen and that edge simply never fires. If you would rather not depend on the text, address the links by position instead: to: ["about", "offer", "contact"].

The back button is an ordinary element inside the view, not a feature you switch on. back: true on the node gives the controller a history to unwind; a control has to exist for someone to unwind it with.

back is history, not an edge

With back: true the destination's own button returns to wherever you came from – the reverse fold over the path actually traversed, not a lookup in the edge list. From aurora the graph above offers a forward route to contact, but back returns to work, because that is the way you came. It is the rule every browser has already taught your users.

The state pointer moves only when a transition completes. A morph interrupted half way has not arrived anywhere, so a second trigger restarts from the state actually departed from.

Fields

FieldMeaning
fromid of the element that morphs
to{ "Label": "id" } or ["id", …]
chainarray of { from, to, … } edges; replaces from/to
effecta transition preset name, or an inline array of raster nodes
durationmilliseconds; default 900
backwire the destination's control to return; default true
liveuse the HTML-in-Canvas backend; default false
fadehow the two captures are combined; default "morph"

Every one of these may also be written per edge inside chain.

Backends

By default a morph uses the snapshot backend: both ends are captured as stills and the shader samples the two. A transition gains nothing from live upload while paying its costs, so live: true is an explicit opt-in – and it is a request, not a guarantee. Where HTML-in-Canvas is unavailable the pipeline falls back to snapshot on its own and the page behaves as it did before.

Validating before you ship

validate_nodes (see the tool server) checks a morph the way it checks everything else: unknown fields, per-edge typos, ids that resolve to nothing. duraton: 900 is otherwise a transition that silently runs at the default speed and reports nothing.