Use the custom element for HTML-driven embeds and render for programmatic mounts. Both give you an engine-positioned box whose inside you paint: your content does not take over dragging, selection, ports or connections. This page mounts connected cards, wires events, and updates a custom dashboard widget without rebuilding the board.
1. Choose the embedding surface
GrafloriaFlowElement implements <grafloria-flow>. Importing @grafloria/element registers that tag. Pass arrays through the element's nodes and edges properties; use JSON attributes when the data lives in markup.
render() takes the spec first, a container or CSS selector second, and options third. It returns a live DiagramInstance. Its input is an object or JSON, not Mermaid text; see Import diagram text and files for text parsing.
| Choose | What you get |
|---|---|
| Element attributes | An HTML embed with JSON data and named DOM events |
| Element properties | Array inputs without JSON encoding, plus diagram and fitView() |
render(spec, target, options) | A direct instance return, instance events, and kit finalization |
Run the JavaScript examples in a browser project with an ESM bundler, such as the project from the JavaScript quick start. Install the packages they import:
bashnpm install @grafloria/element @grafloria/engine @grafloria/renderer
This property-based embed uses NodeSpec and EdgeSpec to draw Build and Publish with a connection between them. Register the DOM listener before appending the element. The ready event's detail contains { diagram }; the example reads the typed diagram property instead of casting an untyped DOM event payload.
tsimport { GrafloriaFlowElement } from '@grafloria/element';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'build', label: 'Build', position: { x: 60, y: 100 },
size: { width: 180, height: 80 } },
{ id: 'publish', label: 'Publish', position: { x: 360, y: 100 },
size: { width: 180, height: 80 } },
];
const edges: EdgeSpec[] = [{ id: 'release', source: 'build', target: 'publish' }];
const flow = document.createElement('grafloria-flow');
if (!(flow instanceof GrafloriaFlowElement)) throw new Error('Element not registered');
flow.style.height = '400px';
flow.setAttribute('theme', 'light');
flow.setAttribute('fit-view', '');
flow.nodes = nodes;
flow.edges = edges;
const status = document.createElement('p');
status.textContent = 'Starting diagram';
flow.addEventListener('grafloria-ready', () => {
status.textContent = `Ready: ${flow.diagram?.getModel().getNodes().length} nodes`;
});
flow.addEventListener('grafloria-selection-change', () => {
const selected = flow.diagram?.getModel().getNodes()
.filter((node) => node.isSelected()).map((node) => node.id) ?? [];
status.textContent = selected.length ? `Selected: ${selected.join(', ')}` : 'No selection';
});
const close = document.createElement('button');
close.textContent = 'Close diagram';
close.onclick = () => { flow.remove(); status.remove(); close.remove(); };
document.body.append(status, close, flow);
Assigning flow.nodes or flow.edges after mounting calls the instance's corresponding setter. Removing the element disposes its instance. For parent sizing arrangements, see Theme a canvas.
Attribute options
The following defaults come from the element's mount logic. Set mount-only attributes before inserting the element.
| Option | Type | Default | What it does |
|---|---|---|---|
nodes, edges | JSON array strings | Empty arrays | Supplies node and edge specs; prefer properties for rich data |
theme | 'light' or 'dark' | 'light' | Selects a shipped theme; unknown names resolve to light |
fit-view | Presence flag | Absent | Frames the content on mount |
readonly | Presence flag | Absent | Configures read-only interaction on mount |
pan | String | Enabled | "false" disables pan on mount |
wheel-zoom | String | Enabled | "false" disables zoom interaction on mount |
zoom | Numeric string | Not supplied by the element | Supplies the initial zoom |
min-zoom, max-zoom | Numeric strings | Not supplied by the element | Supplies mount-time zoom bounds |
highlight-connected | Flag, "false", "trace", or depth string | Off | Highlights selected nodes' connections; "trace" follows every path |
DOM events
Every forwarded event bubbles and is composed, so it can cross a surrounding shadow boundary. Read the payload from CustomEvent.detail when your host supplies a typed event adapter.
| DOM event | Detail |
|---|---|
grafloria-ready | { diagram } |
grafloria-nodes-change | { nodes } with live node models |
grafloria-edges-change | { edges } with live link models |
grafloria-selection-change | { nodes, edges } |
grafloria-connect | { link } |
grafloria-node-click | { node, world } |
grafloria-edge-click | { edge, world } |
grafloria-viewport-change | { viewport, zoom } |
For a programmatic mount, use instance.on('selection:change', handler) instead. It infers the payload type and returns a function that removes the subscription. The programmatic example in the next step shows that cleanup on Close.
Rename the tag
Grafloria exposes the intended call Grafloria.define('project-flow').
Known issue:
Grafloria.define('project-flow')reuses the constructor already registered asgrafloria-flow, which the browser rejects for a second tag. Until it is fixed, register a subclass under your own tag.
This browser example registers the subclass once and mounts a Release node under <project-flow>:
tsimport { GrafloriaFlowElement } from '@grafloria/element';
import type { NodeSpec } from '@grafloria/renderer';
if (!customElements.get('project-flow')) {
class ProjectFlow extends GrafloriaFlowElement {}
customElements.define('project-flow', ProjectFlow);
}
const flow = document.createElement('project-flow');
if (!(flow instanceof GrafloriaFlowElement)) throw new Error('Unexpected tag constructor');
const nodes: NodeSpec[] = [
{ id: 'release', label: 'Release', position: { x: 80, y: 90 },
size: { width: 180, height: 80 } },
];
flow.nodes = nodes;
flow.style.height = '400px';
flow.setAttribute('fit-view', '');
document.body.append(flow);
2. Fill custom node hosts
Register a JavaScript renderer before mounting, and set custom: true on each node that uses it. Registering a type alone does not opt JavaScript or React nodes into HTML rendering. React uses nodeTypes; Vue exact #node-<type> slots and Angular typed templates opt matching specs in automatically. Vue's wildcard #node slot still needs custom: true.
A JavaScript renderer is a mount hook, not a data binding: it runs once when the host is created. Later node-data changes do not reinvoke it. Update the DOM you own. A missing renderer leaves an empty host, and registering later does not back-fill that host. Style a child of the host; the engine owns the host's position and dimensions.
Use NodeSpec and EdgeSpec in the shared file below. All framework tabs import it. The cards display Build and Publish, and the wire remains attached when you drag either card.
tsimport type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
export const nodes: NodeSpec[] = [
{ id: 'build', type: 'card', custom: true, position: { x: 60, y: 100 },
size: { width: 220, height: 110 }, data: { title: 'Build' } },
{ id: 'publish', type: 'card', custom: true, position: { x: 380, y: 100 },
size: { width: 220, height: 110 }, data: { title: 'Publish' } },
];
export const edges: EdgeSpec[] = [{ id: 'release', source: 'build', target: 'publish' }];
export const cardStyle = {
boxSizing: 'border-box' as const, height: '100%', padding: '12px',
border: '1px solid #94a5f0', borderRadius: '10px', background: '#fff',
color: '#232a3d', fontFamily: 'system-ui',
};
Install your binding in addition to the three shared packages above.
JavaScript:
bashnpm install @grafloria/element @grafloria/engine @grafloria/renderer
Angular:
bashnpm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer
Qwik:
bashnpm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer
React:
bashnpm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer
Vue:
bashnpm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer
Use these components in an existing framework project. React's GrafloriaFlow maps types to components accepting NodeProps. Qwik's GrafloriaFlow uses Qwik components and its own NodeProps. Vue's GrafloriaFlow uses named slots. Angular's DiagramCanvasComponent uses ng-template[grafloriaNode], declared by GrafloriaNodeDefDirective.
Each tab exposes a card-local Mark complete button. It changes that card's content to Complete, without pretending that a mount hook is a reactive binding. Selection updates the status outside the canvas through the binding's own event.
tsimport { Grafloria, render } from '@grafloria/element';
import { nodes, edges, cardStyle } from './content';
Grafloria.registerNodeType('card', (node, host) => {
const card = document.createElement('div');
Object.assign(card.style, cardStyle);
const title = document.createElement('strong');
title.textContent = String(node.getData('title') ?? '');
const complete = document.createElement('button');
complete.textContent = 'Mark complete';
complete.onclick = () => { title.textContent = 'Complete'; };
card.append(title, document.createElement('br'), complete);
host.replaceChildren(card);
});
const canvas = document.createElement('div');
canvas.style.height = '400px';
const status = document.createElement('p');
status.textContent = 'Select a card';
const close = document.createElement('button');
close.textContent = 'Close diagram';
document.body.append(status, close, canvas);
const instance = render({ nodes, edges }, canvas, { fitView: true });
const off = instance.on('selection:change', ({ nodes: selected }) => {
status.textContent = selected.map((node) => node.id).join(', ') || 'No selection';
});
close.onclick = () => {
off();
instance.dispose();
canvas.remove();
status.remove();
close.remove();
};
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular';
import { nodes, edges } from './content';
@Component({
selector: 'app-custom-content',
standalone: true,
imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective],
template: `
<p>{{ status }}</p>
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
(selectionChange)="status = $event.nodes.length + ' selected'"
style="display:block;height:400px">
<ng-template grafloriaNode="card" let-node let-data="data">
<div style="box-sizing:border-box;height:100%;padding:12px;border:1px solid #94a5f0;
border-radius:10px;background:white;color:#232a3d;font-family:system-ui">
<strong>{{ completed.has(node.id) ? 'Complete' : data['title'] }}</strong><br>
<button (click)="completed.add(node.id)">Mark complete</button>
</div>
</ng-template>
</grafloria-diagram-canvas>
`,
})
export class CustomContentComponent {
nodes = nodes;
edges = edges;
status = 'Select a card';
completed = new Set<string>();
}
tsximport { component$, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow, type NodeProps } from '@grafloria/qwik';
import { nodes, edges, cardStyle } from './content';
const Card = component$((props: NodeProps) => {
const complete = useSignal(false);
return <div style={cardStyle}>
<strong>{complete.value ? 'Complete' : String(props.data['title'] ?? '')}</strong><br />
<button onClick$={() => { complete.value = true; }}>Mark complete</button>
</div>;
});
export default component$(() => {
const status = useSignal('Select a card');
return <>
<p>{status.value}</p>
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} fitView
onSelectionChange$={(change) => {
status.value = `${change.nodes.length} selected`;
}} />
</div>
</>;
});
tsximport { useState } from 'react';
import { GrafloriaFlow, type NodeProps } from '@grafloria/react';
import { nodes, edges, cardStyle } from './content';
function Card({ data }: NodeProps) {
const [complete, setComplete] = useState(false);
return <div style={cardStyle}>
<strong>{complete ? 'Complete' : String(data['title'] ?? '')}</strong><br />
<button onClick={() => setComplete(true)}>Mark complete</button>
</div>;
}
export default function CustomContent() {
const [status, setStatus] = useState('Select a card');
return <>
<p>{status}</p>
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} fitView
onSelectionChange={({ nodes: selected }) => setStatus(`${selected.length} selected`)} />
</div>
</>;
}
vue<script setup lang="ts"> import { defineComponent, h, ref } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import { nodes, edges, cardStyle } from './content'; const status = ref('Select a card'); const Card = defineComponent({ props: { title: { type: String, required: true } }, setup(props) { const complete = ref(false); return () => h('div', { style: cardStyle }, [ h('strong', complete.value ? 'Complete' : props.title), h('br'), h('button', { onClick: () => { complete.value = true; } }, 'Mark complete'), ]); }, }); </script> <template> <p>{{ status }}</p> <div style="height:400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" fit-view @selection-change="status = $event.nodes.length + ' selected'"> <template #node-card="{ data }"> <Card :title="String(data['title'] ?? '')" /> </template> </GrafloriaFlow> </div> </template>
The JavaScript mount includes a Close diagram button above the connected cards.
The Angular template fills both cards and leaves the surrounding node outlines and ports visible.
The Qwik components draw the card content inside the connected hosts.
The React components and Vue slot components produce the same initial card content: Build and Publish, each with a Mark complete button.
Vue slots do not repaint for in-place node-data changes. Put a component in the slot that subscribes to your own reactive source, rather than relying on slot reinvocation. The Vue card above owns a reactive completion flag. Qwik custom nodes occupy separate Qwik containers: keep them self-contained and pass their initial payload through node data, not surrounding app context.
Use a template for markup-driven cards
For a CMS or static page, place a <template data-node-type="template-card"> inside the element. This alternative clones the template for the Build node. data-field="title" reads the data bag; data-field="id" reads the node id. Missing and null values become empty strings. A registered renderer takes precedence over a matching template.
html<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Template card</title></head>
<body>
<grafloria-flow style="height:400px" fit-view
nodes='[{"id":"build","type":"template-card","custom":true,"position":{"x":80,"y":90},"size":{"width":220,"height":110},"data":{"title":"Build"}}]'>
<template data-node-type="template-card">
<div style="box-sizing:border-box;height:100%;padding:12px;border:1px solid #94a5f0;border-radius:10px;background:white">
<strong data-field="title"></strong>
<p data-field="id"></p>
</div>
</template>
</grafloria-flow>
<script type="module" src="/src/register.ts"></script>
</body>
</html>
tsimport '@grafloria/element';
Template substitution uses textContent, not HTML injection. Use the same rule in your JavaScript renderers: never interpolate user-supplied text into innerHTML. Templates share the mount-once contract; use owned DOM or widget handles when content must change later. The canvas uses light DOM, so your page stylesheet applies to the cloned children.
3. Update custom dashboard content through a handle
Use a WidgetHandle to repaint your custom content without remounting the board; see Arrange dashboard containers for creating and mounting the board and obtaining its handles.
The kit ships KPI, line, bar, donut, funnel and table painters. Delegate those kinds to defaultWidgetRenderer before adding your own painter. Type the data with DashboardWidgetSpec and the painter with WidgetRenderer. This board draws a shipped Builds KPI beside a custom Release note. Update note changes the note to Ready to publish without replacing the board or its instance.
tsimport { dashboard, defaultWidgetRenderer, render } from '@grafloria/element';
import type { DashboardWidgetSpec, WidgetRenderer } from '@grafloria/element';
const widgets: DashboardWidgetSpec[] = [
{ id: 'builds', kind: 'kpi', span: 3, rows: 1,
data: { label: 'Builds', value: '24' } },
{ id: 'note', kind: 'release-note', span: 3, rows: 1,
title: 'Release note', data: { text: 'Waiting for review' } },
];
const paint: WidgetRenderer = (widget, host) => {
if (widget.kind !== 'release-note') {
defaultWidgetRenderer(widget, host);
return;
}
const card = document.createElement('div');
Object.assign(card.style, {
boxSizing: 'border-box', height: '100%', padding: '12px',
background: '#fff', border: '1px solid #94a5f0', borderRadius: '10px',
fontFamily: 'system-ui', color: '#232a3d',
});
const title = document.createElement('strong');
title.textContent = widget.title ?? '';
const text = document.createElement('p');
text.textContent = String(widget.data?.['text'] ?? '');
card.append(title, text);
host.replaceChildren(card);
};
const canvas = document.createElement('div');
canvas.style.height = '400px';
const update = document.createElement('button');
update.textContent = 'Update note';
const close = document.createElement('button');
close.textContent = 'Close board';
document.body.append(update, close, canvas);
const spec = dashboard({ columns: 6, widgets, renderWidget: paint });
const instance = render(spec, canvas);
const handle = spec.handle;
update.onclick = () => {
handle.widget('note')?.update({ data: { text: 'Ready to publish' } });
};
close.onclick = () => {
handle.dispose();
instance.dispose();
canvas.remove();
update.remove();
close.remove();
};
WidgetRenderer receives the declared widget spec and its HTML host. It mounts once during ordinary rendering; the widget handle explicitly invokes it again for content updates.
| Operation or option | Type | Default | What it does |
|---|---|---|---|
renderWidget | WidgetRenderer | Shipped painter | Fills each widget's host |
columns | number | 12 | Sets the board's column count |
rowHeight | number | 130 | Sets row height in grow mode |
update(patch) | Partial data, title, kind fields | No patch | Replaces supplied fields and repaints; returns void |
repaint() | No arguments | No automatic data binding | Reinvokes the painter with the current declared spec; returns void |
update() replaces data; it does not merge keys into the old payload. Supply every data field the painter needs. If your painter reads an external store, change that store and call handle.widget('note')?.repaint() to refresh the same host. Neither call recreates the widget node.
The handle calls are shared across bindings. For React widget components, Vue widget slots, Angular widget templates and Qwik widget components, use the complete mounted examples in Build a dashboard.
Demos and next steps
- Custom nodes live demo and its source: drag connected, custom-shaped nodes.
- HTML nodes live demo: inspect rich content that moves with a node.
- Dashboard builder live demo: see the shipped painters on a rearrangeable board.
- Instance and lifecycle: keep an instance and clean it up with its host.
- State and event flow: choose who owns spec data and return edits to that owner.
- Validate port connections: constrain connections around custom content.
Was this page helpful?