Use a custom node when a node needs content beyond Grafloria's built-in label and shape. Declare nodes as NodeSpec values and connect them with EdgeSpec edges. The framework bindings put your own component or template inside the node; the diagram still owns its geometry, hit-testing, selection and connections. The plain JavaScript example uses a structured HTML body inside an SVG foreignObject.
1. Render content inside a node
Give each node a type, a real size, and data for its content. Then connect node IDs with edges. In React, register a component for the type and set custom: true; Vue and Angular use the matching type declaration to opt into framework templates. Plain JavaScript and Qwik can use the structured metadata.html body shown below.
The sample renders two connected service cards. Each framework's node body stays inside its box as the diagram moves and zooms; the link remains attached to the node geometry. The HTML-node demo shows a card with a heading, progress line and badge inside its node.
The examples use these node fields:
| Field | Type | Default | What it does |
|---|---|---|---|
type | string | 'rect' | Selects the custom-node type rendered by a matching component, slot or template. |
size | { width: number; height: number } | Not specified | Sets the node box that contains the custom content. |
data | Record<string, any> | Not specified | Carries the node payload to custom components and templates. |
custom | boolean | Not specified | On React nodes, true routes the node through the HTML layer. Vue and Angular also opt in from a matching declared type. |
Framework examples
Use render in the browser to mount a data spec. For structured HTML, put a sanitized content tree in metadata.html.content; the body renders inside the node's foreignObject, so it moves with the node group. The code creates a host with a real height.
Angular's DiagramCanvasComponent and exact grafloriaNode template, Qwik's GrafloriaFlow, React's GrafloriaFlow, and Vue's GrafloriaFlow and named slot are the framework front doors. Angular and Vue opt matching template types in automatically; React uses both a nodeTypes entry and custom: true. In Qwik, mount the flow with Qwik's renderer and pass nodes with structured HTML metadata.
jsimport { render } from '@grafloria/element';
const nodes = [
{
id: 'api',
position: { x: 80, y: 100 },
size: { width: 210, height: 100 },
metadata: {
html: {
content: {
tag: 'div',
className: 'service-card',
children: [
{ tag: 'strong', text: 'API gateway' },
{ tag: 'span', className: 'status', text: 'Healthy' },
],
},
},
},
},
{
id: 'worker',
position: { x: 400, y: 100 },
size: { width: 210, height: 100 },
metadata: {
html: {
content: {
tag: 'div',
className: 'service-card',
children: [
{ tag: 'strong', text: 'Order worker' },
{ tag: 'span', className: 'status', text: 'Running' },
],
},
},
},
},
];
const edges = [{ id: 'api-worker', source: 'api', target: 'worker' }];
const target = document.createElement('div');
target.style.height = '420px';
target.style.width = '100%';
target.id = 'diagram';
document.body.append(target);
const style = document.createElement('style');
style.textContent = '#diagram .service-card { font: 14px/1.4 system-ui, sans-serif; padding: 12px; } #diagram .service-card strong { display: block; } #diagram .service-card .status { color: #059669; font-size: 12px; }';
document.head.append(style);
const instance = render({ nodes, edges }, target);
instance.fitView();
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
@Component({
selector: 'app-board',
standalone: true,
imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective],
template: `
<grafloria-diagram-canvas [nodes]="nodes" [edges]="edges"
style="display:block; height:420px">
<ng-template grafloriaNode="service" let-data="data">
<div class="service-card">
<strong>{{ data['name'] }}</strong>
<span>{{ data['status'] }}</span>
</div>
</ng-template>
</grafloria-diagram-canvas>
`,
styles: [`.service-card { height: 100%; box-sizing: border-box; padding: 12px;
border: 1px solid #94a5f0; border-radius: 10px; background: white; }
.service-card strong, .service-card span { display: block; }
.service-card span { color: #059669; font-size: 12px; }`],
})
export class BoardComponent {
nodes: NodeSpec[] = [
{ id: 'api', type: 'service', position: { x: 80, y: 100 }, size: { width: 210, height: 100 }, data: { name: 'API gateway', status: 'Healthy' } },
{ id: 'worker', type: 'service', position: { x: 400, y: 100 }, size: { width: 210, height: 100 }, data: { name: 'Order worker', status: 'Running' } },
];
edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }];
}
tsximport { render as renderQwik } from '@builder.io/qwik';
import { GrafloriaFlow, type EdgeSpec, type NodeSpec } from '@grafloria/qwik';
const nodes: NodeSpec[] = [
{
id: 'api',
position: { x: 80, y: 100 },
size: { width: 210, height: 100 },
metadata: {
html: {
content: {
tag: 'div',
children: [
{ tag: 'strong', text: 'API gateway' },
{ tag: 'span', text: 'Healthy' },
],
},
},
},
},
{
id: 'worker',
position: { x: 400, y: 100 },
size: { width: 210, height: 100 },
metadata: {
html: {
content: {
tag: 'div',
children: [
{ tag: 'strong', text: 'Order worker' },
{ tag: 'span', text: 'Running' },
],
},
},
},
},
];
const edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }];
const target = typeof document === 'undefined' ? null : document.createElement('div');
if (target) {
target.style.height = '420px';
document.body.append(target);
void renderQwik(target, <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} fitView />);
}
tsximport { GrafloriaFlow } from '@grafloria/react';
import type { EdgeSpec, NodeProps, NodeSpec, NodeTypes } from '@grafloria/react';
function ServiceNode({ id, selected }: NodeProps<never>) {
return (
<div style={{
boxSizing: 'border-box',
width: '100%',
height: '100%',
padding: '12px',
border: selected ? '2px solid #3b52d9' : '1px solid #94a5f0',
borderRadius: 10,
background: '#fff',
font: '14px/1.4 system-ui, sans-serif',
}}>
<strong>{id}</strong>
<div>Service node</div>
</div>
);
}
const nodeTypes: NodeTypes = { service: ServiceNode };
const nodes: NodeSpec[] = [
{ id: 'api', type: 'service', custom: true, position: { x: 80, y: 100 }, size: { width: 210, height: 100 } },
{ id: 'worker', type: 'service', custom: true, position: { x: 400, y: 100 }, size: { width: 210, height: 100 } },
];
const edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }];
export function Board() {
return (
<div style={{ height: '420px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={nodeTypes} fitView />
</div>
);
}
vue<script setup lang="ts"> import { GrafloriaFlow, type EdgeSpec, type NodeSpec } from '@grafloria/vue'; const nodes: NodeSpec[] = [ { id: 'api', type: 'service', position: { x: 80, y: 100 }, size: { width: 210, height: 100 }, data: { name: 'API gateway', status: 'Healthy' } }, { id: 'worker', type: 'service', position: { x: 400, y: 100 }, size: { width: 210, height: 100 }, data: { name: 'Order worker', status: 'Running' } }, ]; const edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }]; </script> <template> <div style="height: 420px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" fit-view> <template #node-service="{ data }"> <div class="service-card"> <strong>{{ data['name'] }}</strong> <span>{{ data['status'] }}</span> </div> </template> </GrafloriaFlow> </div> </template> <style> .service-card { box-sizing: border-box; height: 100%; padding: 12px; border: 1px solid #94a5f0; border-radius: 10px; background: white; } .service-card strong, .service-card span { display: block; } .service-card span { color: #059669; font-size: 12px; } </style>
In plain JavaScript, render() returns the mounted instance and fitView() frames both cards and their connecting edge. Grafloria sanitizes the structured tree rather than inserting it as raw HTML.
Angular renders the template with the node's data payload inside the sized canvas. Read payload keys with index syntax because the template context uses Record<string, unknown>. The GrafloriaNodeDefDirective import is required for grafloriaNode to match.
Qwik's renderer mounts the flow in the browser when document exists. The flow shows the two cards from their structured HTML trees and connects them with the edge.
React renders a custom component for both nodes; each card shows its node ID and a selection-aware border, and each React spec sets custom: true.
Vue renders the named slot for each matching node and exposes its data in the slot scope. Both service cards fill their node boxes while the edge connects them.
2. Keep the content within the node box
Set size on each node; the diagram uses that geometry to size and position the custom-content host. Make the root element fill the box with height: 100% and box-sizing: border-box, so padding and borders fit within the node's hit area. Give the canvas parent a real height as well.
In React, a missing or mismatched custom: true flag or nodeTypes key means the custom component does not render. In Vue and Angular, match the type exactly to the named slot or template directive. For Angular, include GrafloriaNodeDefDirective in the component's imports; without it the grafloriaNode template is ignored.
The JavaScript metadata.html route is sanitized and structured. Supply element descriptions and text values rather than interpolating untrusted values into raw HTML.
3. See the nodes running
- Live HTML-node demo — structured HTML rendered inside a node.
- Custom-node demo — built-in terminal, predefined-process and document shapes connected in one diagram.
- Custom-node demo source
If you need a different built-in silhouette or fill rather than custom content, use the per-node shape option; the custom-node demo shows terminal, predefined-process and document silhouettes. For node sizing and geometry, see Resize and size nodes.
Was this page helpful?