Render content keyed by nodeId while Grafloria keeps the node's identity, selection, ports, links, routing, and serialized data. Use this when a rectangle is not enough—for example, to show a service name, owner, and status inside a node.
The model
Put the node's stable key in id, the renderer key in type, and application data in data. A NodeSpec holds that data and geometry; an EdgeSpec holds a connection. Keep the node's geometry in position and size; keep connections in edges. The custom content is the inside of the box. The diagram still owns hit-testing, dragging, ports, selection, and link geometry.
The component receives the node's data and its live selection state. In React, describe those values with NodeProps; in plain JavaScript, use the HTML-layer representation supplied by the node spec. A node with type: 'card' is rendered by the card entry in the framework's node registry or slot.
Plain JavaScript
Use render with a data-backed HTML description. See the custom-nodes demo for the running result. metadata.html is sanitized and placed inside the node's transformed HTML layer, so moving the node moves its content with it. The id and data remain part of the ordinary node document.
jsimport { render } from '@grafloria/element';
const host = document.querySelector('#app');
if (!(host instanceof HTMLElement)) {
throw new Error('The #app element is required');
}
host.style.height = '400px';
const nodeId = 'deploy';
const instance = render({
nodes: [{
id: nodeId,
position: { x: 120, y: 100 },
size: { width: 260, height: 140 },
data: { nodeId, title: 'Deploy #4213', status: 'staging' },
metadata: {
html: {
content: {
tag: 'div',
children: [
{ tag: 'strong', text: nodeId + ': Deploy #4213' },
{ tag: 'p', text: 'building → 72%' },
{ tag: 'span', className: 'badge', text: 'staging' },
],
},
},
},
}],
edges: [],
}, host);
instance.fitView();
The mounted diagram shows a card containing “Deploy #4213”, “building → 72%”, and a “staging” badge. The card remains a diagram node, rather than a floating element, when the user pans or drags it. Give #app a real height; a host without height has no canvas area to paint.
Framework bindings
The following examples use the same two nodes and one link. Each custom node is keyed by type: 'card', while the application values are keyed in data.nodeId; the body can choose its content from that key. In the framework bindings, GrafloriaFlow mounts the diagram. The container has a height so the mounted diagram is visible.
tsximport { useEffect } from 'react';
import { GrafloriaFlow, type NodeProps } from '@grafloria/react';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
type CardData = { nodeId: string; title: string; owner: string; status: string };
const nodes: NodeSpec[] = [
{ id: 'build', type: 'card', custom: true, position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'build', title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'deploy', type: 'card', custom: true, position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'deploy', title: 'Deploy', owner: 'CD', status: 'ready' } },
];
const edges: EdgeSpec[] = [{ id: 'build-deploy', source: 'build', target: 'deploy' }];
function Card({ data, selected }: NodeProps<CardData>) {
return <div style={{ height: '100%', boxSizing: 'border-box', padding: 14, border: '2px solid #94A5F0', borderRadius: 12, background: selected ? '#eef2ff' : '#fff' }}>
<strong>{data.title}</strong><div>{data.nodeId}</div><div>owner: {data.owner}</div><span>{data.status}</span>
</div>;
}
export default function App() {
useEffect(() => undefined, []);
return <div style={{ height: '400px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} /></div>;
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'build', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'build', title: 'Build', owner: 'CI', status: 'passing' } }, { id: 'deploy', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'deploy', title: 'Deploy', owner: 'CD', status: 'ready' } }, ]; const edges: EdgeSpec[] = [{ id: 'build-deploy', source: 'build', target: 'deploy' }]; </script> <template> <div style="height:400px"><GrafloriaFlow :default-nodes="nodes" :default-edges="edges"> <template #node-card="{ data }"><div style="height:100%; box-sizing:border-box; padding:14px; border:2px solid #94A5F0; border-radius:12px; background:#fff"> <strong>{{ data.title }}</strong><div>{{ data.nodeId }}</div><div>owner: {{ data.owner }}</div><span>{{ data.status }}</span> </div></template> </GrafloriaFlow></div> </template>
tsximport { component$ } from '@builder.io/qwik';
import { GrafloriaFlow, type EdgeSpec, type NodeProps, type NodeSpec } from '@grafloria/qwik';
type CardData = { nodeId: string; title: string; owner: string; status: string };
const Card = component$(({ data, selected }: NodeProps<CardData>) => (
<div style={{ height: '100%', boxSizing: 'border-box', padding: '14px', border: '2px solid #94A5F0', borderRadius: '12px', background: selected ? '#eef2ff' : '#fff' }}>
<strong>{data.title}</strong><div>{data.nodeId}</div><div>owner: {data.owner}</div><span>{data.status}</span>
</div>
));
const nodes: NodeSpec[] = [
{ id: 'build', type: 'card', custom: true, position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'build', title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'deploy', type: 'card', custom: true, position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'deploy', title: 'Deploy', owner: 'CD', status: 'ready' } },
];
const edges: EdgeSpec[] = [{ id: 'build-deploy', source: 'build', target: 'deploy' }];
export default component$(() => (
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} />
</div>
));
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
@Component({
standalone: true,
imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective],
template: `
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges" style="display:block; height:400px">
<ng-template grafloriaNode="card" let-data="data">
<div style="height:100%; box-sizing:border-box; padding:14px; border:2px solid #94A5F0; border-radius:12px; background:#fff">
<strong>{{ data['title'] }}</strong><div>{{ data['nodeId'] }}</div><div>owner: {{ data['owner'] }}</div><span>{{ data['status'] }}</span>
</div>
</ng-template>
</grafloria-diagram-canvas>
`,
})
export class CustomNodesComponent {
nodes: NodeSpec[] = [
{ id: 'build', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'build', title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'deploy', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { nodeId: 'deploy', title: 'Deploy', owner: 'CD', status: 'ready' } },
];
edges: EdgeSpec[] = [{ id: 'build-deploy', source: 'build', target: 'deploy' }];
}
React requires custom: true on the spec when the node is rendered by nodeTypes. Vue slots and Angular templates opt into the HTML layer through their bindings. In every case, the custom body remains inside the node host, so the link stays attached when the node moves and the node remains selectable and connectable.
Updating and persisting the content
Keep the node key stable when application data changes. For a live model obtained from the instance, update through the model's tracked setters and then call DiagramInstancerenderNow() when you need the repaint immediately; do not replace the host's geometry styles. In plain JavaScript, custom renderer content mounts once, so update DOM that your renderer owns or use a framework binding that re-renders from its data.
The node's data, id, geometry, and type are part of the document. Keep those values in your NodeSpec and edges in EdgeSpec; the custom component is presentation, not a second model. This lets the same diagram remain routable and round-trip through serialization.
Pitfalls
- A plain JavaScript or React custom node without
custom: truerenders as a normal rectangle; the renderer or component is not consulted. Vue slots and Angular templates opt in automatically. - Register or provide the node type before mounting. An unknown custom type leaves an empty host rather than creating a built-in body.
- Put geometry in
position: { x, y }andsize: { width, height }; top-levelxandyare not node-spec fields. - Interpolate user-supplied values as text. Do not place them in
innerHTML. - A custom node does not replace ports. Use ordinary edges and declared ports when connection direction or routing matters.
Was this page helpful?