Render custom content inside nodes
Use custom node content when the inside of a node is application UI rather than a stock SVG shape. Grafloria keeps the node's geometry, hit testing, ports, links, selection, and serialization; your framework renders the node body.
The canvas needs a parent with a real height. The examples below render a pair of cards and a link between them, so you can drag the cards and see the link follow their geometry.
Choose the rendering surface
- Plain JavaScript uses the HTML metadata renderer or a registered node renderer.
- Angular uses an
ng-templatefor a node type. - Qwik and React use a component mapped by
nodeTypes. - Vue uses a
#node-<type>slot.
Set a node's size, and make the custom content fill that box with height: 100% and
box-sizing: border-box. Ports remain part of the node specification; they do not belong in the
custom component.
Render HTML in plain JavaScript
Use render with HTML metadata when you want a
sanitised, declarative HTML subtree. Grafloria mounts it inside the node's transformed HTML
layer, so it moves with the node.
tsimport { render } from '@grafloria/element';
const target = document.getElementById('canvas');
if (!target) {
throw new Error('Missing #canvas');
}
target.style.height = '400px';
const instance = render({
nodes: [
{
id: 'build',
position: { x: 80, y: 90 },
size: { width: 230, height: 110 },
metadata: {
html: {
content: {
tag: 'div',
children: [
{ tag: 'strong', text: 'Build' },
{ tag: 'div', text: 'owner: CI' },
{ tag: 'span', className: 'badge', text: 'passing' },
],
},
},
},
},
{
id: 'deploy',
position: { x: 430, y: 90 },
size: { width: 230, height: 110 },
label: 'Deploy',
},
],
edges: [{ id: 'build-deploy', source: 'build', target: 'deploy' }],
}, target);
instance.fitView();
The first node shows a bold title, owner text, and a badge inside a foreignObject; the second
node remains a stock node. The HTML description is written as text and elements, not injected as
raw innerHTML. A later change to the model does not re-run a one-time custom mount; update DOM
you own or use a framework component with its own reactive source.
html<div id="canvas" style="height: 400px"></div>
Use a custom component
Give the custom node a type, provide its data, and use the binding's custom-content mechanism.
The following examples all render the same two cards and edge.
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:10px 14px;
border:1.5px solid #94A5F0; border-radius:12px;
background:#fff; font-family:inherit">
<div style="font-weight:700">{{ data['title'] }}</div>
<div style="font-size:12px; color:#5A6478">owner: {{ data['owner'] }}</div>
<span style="font-size:11px; font-weight:600; color:#059669">{{ data['status'] }}</span>
</div>
</ng-template>
</grafloria-diagram-canvas>
`,
})
export class CustomNodeContentComponent {
nodes = [
{ id: 'build', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'deploy', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Deploy', owner: 'CD', status: 'ready' } },
];
edges = [{ id: 'build-deploy', source: 'build', target: 'deploy' }];
}
tsximport { component$ } from '@builder.io/qwik';
import { GrafloriaFlow, type NodeProps, type NodeSpec, type EdgeSpec } from '@grafloria/qwik';
const Card = component$((props: NodeProps<never>) => (
<div style={{ height: '100%', boxSizing: 'border-box', padding: '10px 14px', border: '1.5px solid #94A5F0', borderRadius: '12px', background: '#fff' }}>
<div style={{ fontWeight: 700 }}>{props.node.getData('title')}</div>
<div style={{ fontSize: '12px', color: '#5A6478' }}>owner: {props.node.getData('owner')}</div>
<span style={{ fontSize: '11px', fontWeight: 600, color: '#059669' }}>{props.node.getData('status')}</span>
</div>
));
const nodes: NodeSpec[] = [
{ id: 'build', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'deploy', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Deploy', owner: 'CD', status: 'ready' } },
];
const edges: EdgeSpec[] = [{ id: 'build-deploy', source: 'build', target: 'deploy' }];
const nodeTypes = { card: Card };
export default component$(() => (
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={nodeTypes} fitView />
</div>
));
tsximport { GrafloriaFlow } from '@grafloria/react';
import type { NodeProps } from '@grafloria/react';
type CardData = { title: string; owner: string; status: string };
function Card({ data, selected }: NodeProps<CardData>) {
return (
<div style={{ height: '100%', boxSizing: 'border-box', padding: '10px 14px', border: selected ? '2px solid #3B52D9' : '1.5px solid #94A5F0', borderRadius: 12, background: '#fff' }}>
<div style={{ fontWeight: 700 }}>{data.title}</div>
<div style={{ fontSize: 12, color: '#5A6478' }}>owner: {data.owner}</div>
<span style={{ fontSize: 11, fontWeight: 600, color: '#059669' }}>{data.status}</span>
</div>
);
}
const nodes = [
{ id: 'build', type: 'card', custom: true, position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'deploy', type: 'card', custom: true, position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Deploy', owner: 'CD', status: 'ready' } },
];
const edges = [{ id: 'build-deploy', source: 'build', target: 'deploy' }];
export default function CustomNodeContent() {
return <div style={{ height: '400px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} fitView /></div>;
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; const nodes = [ { id: 'build', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Build', owner: 'CI', status: 'passing' } }, { id: 'deploy', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 }, data: { title: 'Deploy', owner: 'CD', status: 'ready' } }, ]; const edges = [{ 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:10px 14px; border:1.5px solid #94A5F0; border-radius:12px; background:#fff"> <div style="font-weight:700">{{ data.title }}</div> <div style="font-size:12px; color:#5A6478">owner: {{ data.owner }}</div> <span style="font-size:11px; font-weight:600; color:#059669">{{ data.status }}</span> </div> </template> </GrafloriaFlow> </div> </template>
Angular's template, Qwik's nodeTypes, React's nodeTypes, and Vue's named slot are the
framework-specific registrations. In each case the rendered content occupies the node box while
the engine continues to draw and hit-test the edge and ports.
Keep content and model data separate
The node payload is data for the content; it is not a second geometry system. Use a live
DiagramInstance when you
need to reconcile nodes or repaint after a model mutation. In a custom component, use the
framework's reactive state for frequently changing application content. A mounted custom host is
not recreated during a drag, so component state survives movement; selection state is supplied by
the binding where that binding exposes it.
Use a stock shape instead of custom content when you only need a terminal, document, or styled
rectangle. A per-node shape keeps that node on the SVG path and avoids a component registration.
Check the result
Open the custom nodes demo to see custom shapes retain their links while moving. The HTML nodes demo shows rich content moving and scaling with the camera.
Pitfalls
- A JavaScript custom renderer must be registered before
render(); an unknown custom type gets an empty host rather than a late replacement. - In React,
custom: trueand an exactnodeTypeskey are both required. - In Vue, an exact
#node-cardslot optstype: 'card'into custom rendering; a wildcard slot does not opt a node in by itself. - Do not put untrusted values into
innerHTML. The declarative HTML path uses text content; use text nodes or framework bindings for user-supplied values. - Do not style the host's geometry. Style a child inside it; the engine rewrites the host's position and size.
Was this page helpful?