# Create custom nodes

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 [`render`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-element-core#render) and a registered renderer.
- React uses [`GrafloriaFlow`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-react#grafloriaflow) with `nodeTypes`.
- Vue uses `GrafloriaFlow` with a `#node-<type>` slot.
- Angular uses [`DiagramCanvasComponent`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) with an `ng-template` for the node type.
- Qwik uses [`GrafloriaFlow`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-qwik#grafloriaflow) with `nodeTypes`.

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.

:::code-group
```js title="JavaScript"
import { 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);
```
```tsx title="React"
import { 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 title="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>
```
```ts title="Angular"
import { 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' }];
}
```
```tsx title="Qwik"
import { 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 custom card and its connected edge rendered inside the sized diagram host.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/5c0e5d12f019b51f9dd54e5848dd1041.png)

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`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-react#nodeprops).

## Use the live model when needed

Framework renderers receive a live [`NodeModel`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-engine-models-nodemodel#nodemodel). Use tracked setters for model changes, then repaint through the [`DiagramInstance`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) when you need a synchronous frame:

```ts
import { 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:

```ts
const 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: true` sends 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 }` and `size: { width, height }` on the spec. Top-level `x` and `y` are not node geometry.
- Make the custom root fill the box with `height: 100%` and `box-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](https://atloria.dev/p/grafloria-h7YM7amryF/developer/build-er-and-uml-diagrams) for the dashboard update pattern.
- Give the canvas parent a resolved height, or use [Style a diagram](https://atloria.dev/p/grafloria-h7YM7amryF/developer/style-a-diagram); a zero-height parent produces a blank canvas.

## See it running

Open the [custom-nodes demo](https://grafloria.com/demos/nodes/custom-nodes.html) to see custom bodies, dragging, ports, and connected edges together. The source is [custom-nodes.html](https://github.com/grafloria/grafloria/blob/ef2bcc55237d4d1f1643b6fea68bd996aa37a9e9/demos/nodes/custom-nodes.html).

Related: [Ports and validation](https://atloria.dev/p/grafloria-h7YM7amryF/developer/ports-and-validation), [Model and document](https://atloria.dev/p/grafloria-h7YM7amryF/developer/model-and-document), and [Element versus render()](https://atloria.dev/p/grafloria-h7YM7amryF/developer/javascript-quick-start).
