Use a custom node when the inside of a node needs application-specific content. Keep a stable id for each node, give the node a real size, and let Grafloria keep ownership of selection, dragging, ports, routing, and serialization.
Choose the rendering surface
- JavaScript uses
renderand a registered renderer. - React uses
GrafloriaFlowwithnodeTypes. - Vue uses
GrafloriaFlowwith a#node-<type>slot. - Angular uses
DiagramCanvasComponentwith anng-templatefor the node type. - Qwik uses
GrafloriaFlowwithnodeTypes.
In the framework bindings, declaring a renderer for a node type opts matching nodes into the HTML layer. In JavaScript, set custom: true explicitly. In every case, the renderer fills a host whose geometry comes from position and size.
Render a custom node
The following examples render two connected cards. Each card keeps its stable id; the engine still owns the edge and node behavior.
jsimport { Grafloria, render } from '@grafloria/element';
Grafloria.registerNodeType('card', (node, element) => {
const card = document.createElement('div');
card.style.cssText = 'height:100%;box-sizing:border-box;padding:12px;border:2px solid #4f46e5;border-radius:10px;background:#fff;font:14px system-ui';
const title = document.createElement('strong');
title.textContent = String(node.getData('title'));
card.append(title);
const id = document.createElement('div');
id.textContent = `node: ${node.id}`;
card.append(id);
element.append(card);
});
const host = document.getElementById('diagram');
if (!host) throw new Error('Missing #diagram');
host.style.height = '400px';
const instance = render({
nodes: [
{ id: 'build', type: 'card', custom: true, position: { x: 80, y: 100 }, size: { width: 220, height: 100 }, data: { title: 'Build' } },
{ id: 'deploy', type: 'card', custom: true, position: { x: 400, y: 100 }, size: { width: 220, height: 100 }, data: { title: 'Deploy' } },
],
edges: [{ id: 'build-to-deploy', source: 'build', target: 'deploy' }],
}, host);
tsximport { GrafloriaFlow } from '@grafloria/react';
import type { NodeProps } from '@grafloria/react';
function Card({ id, selected }: NodeProps<never>) {
return <div style={{ height: '100%', boxSizing: 'border-box', padding: 12, border: `2px solid ${selected ? '#dc2626' : '#4f46e5'}`, borderRadius: 10, background: '#fff', font: '14px system-ui' }}>
<strong>{id === 'build' ? 'Build' : 'Deploy'}</strong>
<div>node: {id}</div>
</div>;
}
const nodes = [
{ id: 'build', type: 'card', custom: true, position: { x: 80, y: 100 }, size: { width: 220, height: 100 } },
{ id: 'deploy', type: 'card', custom: true, position: { x: 400, y: 100 }, size: { width: 220, height: 100 } },
];
const edges = [{ id: 'build-to-deploy', source: 'build', target: 'deploy' }];
export function CustomNodes() {
return <div style={{ height: '400px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} /></div>;
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; const nodes = [ { id: 'build', type: 'card', position: { x: 80, y: 100 }, size: { width: 220, height: 100 } }, { id: 'deploy', type: 'card', position: { x: 400, y: 100 }, size: { width: 220, height: 100 } }, ]; const edges = [{ id: 'build-to-deploy', source: 'build', target: 'deploy' }]; </script> <template> <div style="height:400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges"> <template #node-card="{ node }"> <div style="height:100%;box-sizing:border-box;padding:12px;border:2px solid #4f46e5;border-radius:10px;background:#fff;font:14px system-ui"> <strong>{{ node.id === 'build' ? 'Build' : 'Deploy' }}</strong> <div>node: {{ node.id }}</div> </div> </template> </GrafloriaFlow> </div> </template>
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular';
@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:12px;border:2px solid #4f46e5;border-radius:10px;background:#fff;font:14px system-ui">
<strong>{{ data['title'] }}</strong>
<div>application card</div>
</div>
</ng-template>
</grafloria-diagram-canvas>`
})
export class CustomNodesComponent {
nodes = [
{ id: 'build', type: 'card', position: { x: 80, y: 100 }, size: { width: 220, height: 100 }, data: { title: 'Build' } },
{ id: 'deploy', type: 'card', position: { x: 400, y: 100 }, size: { width: 220, height: 100 }, data: { title: 'Deploy' } },
];
edges = [{ id: 'build-to-deploy', source: 'build', target: 'deploy' }];
}
tsximport { component$ } from '@builder.io/qwik';
import { GrafloriaFlow, type NodeProps, type NodeTypes } from '@grafloria/qwik';
const Card = component$(({ id, selected }: NodeProps<never>) => (
<div style={{ height: '100%', boxSizing: 'border-box', padding: '12px', border: `2px solid ${selected ? '#dc2626' : '#4f46e5'}`, borderRadius: '10px', background: '#fff', font: '14px system-ui' }}>
<strong>{id === 'build' ? 'Build' : 'Deploy'}</strong>
<div>node: {id}</div>
</div>
));
const nodeTypes: NodeTypes = { card: Card };
const nodes = [
{ id: 'build', type: 'card', position: { x: 80, y: 100 }, size: { width: 220, height: 100 } },
{ id: 'deploy', type: 'card', position: { x: 400, y: 100 }, size: { width: 220, height: 100 } },
];
const edges = [{ id: 'build-to-deploy', source: 'build', target: 'deploy' }];
export default component$(() => <div style={{ height: '400px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={nodeTypes} /></div>);
The mounted result is two application-rendered cards joined by a routed edge. Select or drag either card: its host moves with the node, while the edge remains attached. In React and Qwik, selected changes the border; the engine supplies that state through NodeProps.
Use the live model when needed
Framework renderers receive a live NodeModel. Use tracked setters for model changes, then repaint through the DiagramInstance when you need a synchronous frame:
tsimport { render } from '@grafloria/element';
const host = document.getElementById('diagram');
if (!host) throw new Error('Missing #diagram');
host.style.height = '400px';
const instance = render({
nodes: [{ id: 'build', position: { x: 80, y: 100 }, size: { width: 220, height: 100 } }],
}, host);
const node = instance.getModel().getNode('build');
if (!node) throw new Error('Missing build node');
node.setMetadata('label', 'Updated build');
instance.renderNow();
The instance returned by render() is the live handle. It also exposes the model that owns nodes and links, so the custom body does not need to implement selection, ports, or edge geometry.
Add ports without putting handles in the body
Ports belong to the node spec, not to the custom component. Declare them when a connection must use named or typed endpoints:
tsconst nodes = [{
id: 'build', type: 'card', custom: true,
position: { x: 80, y: 100 }, size: { width: 220, height: 100 },
ports: [
{ id: 'out', side: 'right', type: 'output', dataType: 'build' },
],
}];
Pitfalls
- In JavaScript, omitting
custom: truesends the node through the stock SVG path, so the registered renderer is never called. Register the type before mounting; a host that mounted without a renderer stays empty. - Keep
position: { x, y }andsize: { width, height }on the spec. Top-levelxandyare not node geometry. - Make the custom root fill the box with
height: 100%andbox-sizing: border-box. - A custom renderer runs at mount, not on every data mutation. Update DOM you own or use a component with its own reactive state. See Build ER and UML diagrams for the dashboard update pattern.
- Give the canvas parent a resolved height, or use Style a diagram; a zero-height parent produces a blank canvas.
See it running
Open the custom-nodes demo to see custom bodies, dragging, ports, and connected edges together. The source is custom-nodes.html.
Related: Ports and validation, Model and document, and Element versus render().
Was this page helpful?