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
| Field | Meaning |
|---|---|
from | id of the element that morphs |
to | { "Label": "id" } or ["id", …] |
chain | array of { from, to, … } edges; replaces from/to |
effect | a transition preset name, or an inline array of raster nodes |
duration | milliseconds; default 900 |
back | wire the destination's control to return; default true |
live | use the HTML-in-Canvas backend; default false |
fade | how 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.