Learn / The element vs render() — choosing your door
The element vs render() — choosing your door
Both doors open onto the same DiagramInstance. The element speaks HTML — attributes in, DOM events out, dispose on disconnect. render() speaks JavaScript — spec in, instance out, every option on the table. This page is the full contract of each, so the choice is mechanical.
Same engine, two grips
<grafloria-flow> | render(spec, target, options) | |
|---|---|---|
| Input | JSON attributes, or properties | A spec object (or its JSON string), incl. kit specs |
| Output | Bubbling DOM CustomEvents | The DiagramInstance, returned |
| Options | The attribute set below | Everything createDiagram() takes |
| Teardown | Automatic on disconnect | api.dispose(), yours to call |
| The instance | el.diagram (the escape hatch) | The return value |
The element's own doc states why it exists: "React Flow serves React. ngx-vflow
serves Angular. Each is a wall around one framework. A custom element has no wall:
Vue, Svelte, Solid, Lit, Alpine, plain HTML, a CMS block, a Jupyter/Observable cell
and a static site all speak 'HTML element with attributes and events'." And
render() is "the Mermaid-shaped top-level API" — one function, no
build step, returning "the same DiagramInstance every wrapper binds
to, so the 'tiny API' is not a lesser one — it is the whole engine, addressed in one
call."
The element: attributes
| Attribute | After mount | Semantics |
|---|---|---|
nodes, edges | live | JSON arrays; a change reconciles into the running diagram |
theme | live | "light" | "dark" (strings only here); swaps in
place — the SVG is patched, not remounted. Unknown values fall back to light. |
fit-view | live | Boolean by presence; setting it after mount re-frames the content |
zoom | live | Number; drives viewport.setZoom() when changed |
readonly | mount-time | Boolean by presence; blocks every mutating gesture but still pans and zooms |
pan, wheel-zoom | mount-time | Opt-out booleans: on by default, disabled only by the literal value
"false" |
min-zoom, max-zoom | mount-time | Numbers; camera clamp |
The mount-time five are read once, deliberately: "they configure the event binder, which is created once. Changing them afterwards is rare enough that we do not tear the instance down."
nodes/edges attribute logs
[grafloria-flow] ignoring malformed JSON attribute: … and falls back to an
empty array — the element still mounts a working (empty) canvas. There is a test that
feeds it {{{ not json to keep this true.The element: properties and methods
const el = document.querySelector('grafloria-flow');
el.nodes = bigGraph.nodes; // the RICH path — real objects, no JSON round-trip
el.edges = bigGraph.edges; // (what Vue/Svelte/Solid template bindings target)
el.fitView(40); // the one convenience method
el.diagram // the DiagramInstance — the escape hatch to everything:
el.diagram.getModel(); // the data
el.diagram.getEngine(); // commands, undo, layout, validation
el.diagram.renderNow(); // force a synchronous paint
el.render(). Attribute and property writes
reconcile the model and the scheduler paints on the next frame; when you must measure
the DOM immediately after a change, go through the instance:
el.diagram.renderNow().el.nodes (or api.setNodes()) adds what is new, patches what
exists and removes what disappeared — an existing id keeps its live
NodeModel. That is what you want for incremental updates; it is
not what you want when the new array carries freshly built models under old
ids. The Mermaid-viewer demo documents the trap: "the reconciler treats a live
model as 'already here' and leaves the existing object alone, so re-applying edited
text would otherwise keep the STALE nodes for every id that still exists." The
fix is clear-then-apply: setNodes([]), then setNodes(next).Live: the clear-then-apply reconciler in the Mermaid viewer →
The element: all eight events
Instance events are forwarded as CustomEvents that bubble and
are composed — they cross shadow boundaries, so a listener on
document works (the test suite proves exactly that):
| Event | e.detail |
|---|---|
grafloria-ready | { diagram } — the instance |
grafloria-nodes-change | { nodes: NodeModel[] } |
grafloria-edges-change | { edges: LinkModel[] } |
grafloria-selection-change | { nodes, edges } |
grafloria-connect | { link: LinkModel } |
grafloria-node-click | { node, world: { x, y } } |
grafloria-edge-click | { edge, world: { x, y } } |
grafloria-viewport-change | { viewport, zoom } |
el.addEventListener('grafloria-connect', (e) => save(e.detail.link));
el.addEventListener('grafloria-nodes-change', (e) => persist(e.detail.nodes));
The instance itself emits a few more (reconnect,
node:doubleclick) — subscribe on el.diagram.on(…) for
those. And teardown is automatic: disconnecting the element disposes the
diagram — after el.remove(), el.diagram is
null and the DOM is gone. Re-connecting mounts fresh.
Renaming the tag
import { Grafloria } from '@grafloria/element';
Grafloria.define('acme-flow'); // same class, your tag name
Importing the package already registers <grafloria-flow> for you;
define() is idempotent and "safe to call on the server (where
customElements does not exist)". The class itself is guarded the
same way, so importing the bundle in Node or a Web Worker throws nothing.
render(spec, target, options)
import { render, DARK_THEME } from '@grafloria/element';
const api = render(
{ nodes: [...], edges: [...] }, // or the JSON string of it, or a kit spec
'#canvas', // an element, or a CSS selector
{ theme: DARK_THEME, fitView: true }
);
The contract, point by point:
- Target — an element or a selector; no match throws
Grafloria.render: no element matched …rather than failing silently. - Spec — an object or its JSON string. A kit spec is a render spec:
render(dashboard({…}), host)andrender(erDiagram({…}), host)are the documented one-liners, and a kit'sfinalize(api)runs automatically so the diagram is fully wired from one call (see Kits). - Custom nodes — resolved with the precedence "explicit option >
spec > registry": your
options.renderCustomNodeoutranks a kit spec's own painter, which outranks registered node types.
spec is
data (an object or its JSON), not a Mermaid-style text DSL. The engine does have a
DSL, but wiring it in is a separate card — render() is the embedding
surface, not a parser." Mermaid text goes through
api.loadText() / importDiagramText() — see
Mermaid & the text format.Options are everything createDiagram() accepts except
nodes/edges. The ones you will actually reach for:
theme,colorMode('system'follows the OS),tokenBridge— see Theming.fitView,zoom,minZoom,maxZoom,viewport— the camera.readonly,enablePan,enableZoom,zoomSensitivity,dragThreshold— the gesture binder (the element's mount-time attributes are exactly these).interaction— passed through to the engine's interaction config.renderer— the full renderer config "for every knob the ergonomic fields above do not name": floating connection points, parallel links, channel nudging, link hit-area width.renderCustomNode/removeCustomNode— the custom-node hook pair; a painter that draws later (fetch, rAF, web font) should return its promise, which is what letsawait api.export(…)wait for exactly the widgets that were not done.
And the pieces people look for on the instance: undo lives on the engine
(await api.getEngine().undo() — ⌘Z is already wired), layout too
(await api.getEngine().layout('elk')), and plugins attach with a plain
static import — import { attachCanvasPlugins } from '@grafloria/element',
then attachCanvasPlugins(api, { minimap: true, controls: true }). The
full surface is in The DiagramInstance.
So: which door?
- The element, when the page is the platform: a CMS block, a static site,
a notebook cell, a framework without a first-class binding — anywhere "HTML in,
events out, cleanup on removal" is the natural shape, and the JSON-attribute
ceiling (plus
el.diagramfor everything above it) is acceptable. render(), when you are writing application code: you want the instance in hand from line one, the full options surface, kit specs, and explicit control of disposal.
It is not a fork in the road. el.diagram is the instance
render() returns, so code written against one door runs unchanged behind
the other.
Where next
- The DiagramInstance — the shared surface both doors hand you.
- Custom nodes in plain JavaScript — renderers and templates over either door.
- Events & interaction — the event model beneath the eight DOM events.