Custom Nodes (#node)
The node slot replaces the content inside a node. It does not replace the whole node shell: positioning, show/hide state, drag entry, selected-state classes, size observation, and default CSS variables are still maintained by relation-graph’s RGNodePeel.
This is important: what you write in #node is the content inside .rg-node, not the outermost .rg-node-peel. Therefore, you usually do not need to handle transform: translate(node.x, node.y) in the slot, and you do not need to register the node with the graph instance yourself.
1. Platform Syntax
| Platform | Syntax | Description |
|---|---|---|
| Vue 3 / Vue 2 | <template #node="{ node, checked, dragging }"> |
Recommended syntax. |
| React | <RGSlotOnNode>{props => ...}</RGSlotOnNode> |
children must be a function. |
| React | nodeSlot={({ node }) => ...} |
Prop-style syntax. Do not use it together with <RGSlotOnNode>. |
| Svelte | <div slot="node" let:node let:checked let:dragging> |
Uses a Svelte named slot. |
2. Slot Props
Type definition in the source:
export type RGNodeSlotProps = {
node: RGNode;
defaultExpandHolderPosition?: string;
dragging?: boolean;
checked?: boolean;
};
| Prop | Type | Role |
|---|---|---|
node |
RGNode |
Current rendered runtime node object. It contains node ID, text, type, position, size, style, business data, and runtime state. |
checked |
boolean | undefined |
Whether the current node is marked as checked by the internal controller. It is usually used for editor controllers, connection controllers, temporary highlights, and similar internal states. |
dragging |
boolean | undefined |
Whether the current node is being dragged. |
defaultExpandHolderPosition |
string | undefined |
Global default expand button position from options.defaultExpandHolderPosition. In current Vue/Svelte normal #node forwarding, only node, checked, and dragging are usually passed explicitly. |
Difference between checked and selected:
| Field/prop | Source | Meaning | Common use |
|---|---|---|---|
node.selected |
RGNode data state |
Node selected by the user or API. | Business selection state; export/persistence may care about it. |
checked |
Render prop | Current target node of the internal controller. | Editing, connecting, temporary operation state. |
dragging |
Render prop | Whether the node is currently being dragged. | Visual feedback during dragging. |
3. Common Fields on node
For the complete node data model, see Node Data Model. In node slots, these fields are most commonly used:
| Field | Type | Use |
|---|---|---|
node.id |
string |
Unique node ID, commonly used for events, test selectors, and business operations. |
node.text |
string | undefined |
Default display text. |
node.type |
string | undefined |
Node type. Use it to dispatch different node templates. |
node.data |
Record<string, any> | undefined |
Business data container. Fields such as avatar, status, count, and permissions should usually go here. |
node.color |
string | undefined |
Main node background color. It is written to --rg-node-color. |
node.fontColor |
string | undefined |
Node text color. It is written to --rg-node-font-color. |
node.borderColor |
string | undefined |
Node border color. It is written to --rg-node-border-color. |
node.borderWidth |
number | undefined |
Node border width in px. |
node.borderRadius |
number | undefined |
Node border radius in px. |
node.width / node.height |
number | undefined |
Fixed node width/height in px. If not set, the content stretches the node. |
node.x / node.y |
number |
Canvas coordinates. The outer node shell applies them. |
node.expanded |
boolean | undefined |
Expansion state in tree/hierarchical data. false means collapsed. |
node.rgChildrenSize |
number |
Runtime count of child nodes. The default expand button depends on it to decide whether to show. |
node.className |
string | undefined |
Class name added to the outer .rg-node-peel. |
node.disablePointEvent |
boolean | undefined |
Disables node events. When enabled, the outer shell adds .rg-node-disable-events. |
4. Outer Structure and Default Classes
The current Vue3/React/Svelte node render structure can be summarized as:
<div class="rg-node-peel rg-node-selected rg-node-shape-1 rg-node-type-user" data-id="node-id">
<!-- Optional: node-expand-button -->
<div class="rg-node">
<!-- Your #node content is here -->
</div>
</div>
The outer .rg-node-peel handles:
| Item | Current behavior |
|---|---|
| Positioning | Uses transform: translate(node.x, node.y). |
| Visibility | Shows/hides according to node.rgCalcedVisibility. |
| Selected class | Adds .rg-node-selected when node.selected is true. |
| Dragging class | Adds .rg-node-dragging when dragging is true. |
| Checked class | Adds .rg-node-checked when checked is true. |
| Node shape class | Adds .rg-node-shape-${node.nodeShape}. Default rectangle is 1. |
| Node type class | Adds .rg-node-type-${node.type}. |
| Custom class | Adds node.className. |
| Event disabled class | Adds .rg-node-disable-events when node.disablePointEvent is true or node.opacity === 0. |
| Style variables | Writes CSS variables for node color, font, border, width/height, opacity, and similar values. |
| Size observation | Calls the instance resize listener after mounting to synchronize actual node width/height. |
Therefore, node slot content should focus on internal content structure and should not reimplement outer state logic.
5. Basic Examples
Vue 3
<script setup lang="ts">
import { RelationGraph } from '@relation-graph/vue';
import type { RGNode } from '@relation-graph/vue';
function openNode(node: RGNode, event: MouseEvent) {
event.stopPropagation();
console.log('open node:', node.id);
}
</script>
<template>
<RelationGraph :options="graphOptions" :initial-data="graphData">
<template #node="{ node, checked, dragging }">
<div
class="user-node"
:class="{
'is-selected': node.selected,
'is-checked': checked,
'is-dragging': dragging
}"
>
<img v-if="node.data?.avatar" class="avatar" :src="node.data.avatar" />
<div class="main">
<div class="title">{{ node.text }}</div>
<div class="desc">{{ node.data?.role || 'Role not set' }}</div>
</div>
<button class="rg-events-all" @click="openNode(node, $event)">Details</button>
</div>
</template>
</RelationGraph>
</template>
React
import {
RelationGraph,
RGSlotOnNode,
type RGNode
} from '@relation-graph/react';
function UserNode({ node, checked, dragging }: {
node: RGNode;
checked?: boolean;
dragging?: boolean;
}) {
return (
<div
className={[
'user-node',
node.selected ? 'is-selected' : '',
checked ? 'is-checked' : '',
dragging ? 'is-dragging' : ''
].filter(Boolean).join(' ')}
>
{node.data?.avatar && <img className="avatar" src={node.data.avatar} />}
<div className="main">
<div className="title">{node.text}</div>
<div className="desc">{node.data?.role || 'Role not set'}</div>
</div>
<button
className="rg-events-all"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
console.log('open node:', node.id);
}}
>
Details
</button>
</div>
);
}
<RelationGraph options={graphOptions} initialData={graphData}>
<RGSlotOnNode>
{(props) => <UserNode {...props} />}
</RGSlotOnNode>
</RelationGraph>
Svelte
<RelationGraph {options} initialData={graphData}>
<div
slot="node"
let:node
let:checked
let:dragging
class:is-selected={node.selected}
class:is-checked={checked}
class:is-dragging={dragging}
class="user-node"
>
{#if node.data?.avatar}
<img class="avatar" src={node.data.avatar} alt="" />
{/if}
<div class="main">
<div class="title">{node.text}</div>
<div class="desc">{node.data?.role || 'Role not set'}</div>
</div>
</div>
</RelationGraph>
6. Dispatch Templates by node.type
For complex business graphs, do not pile many if branches into one node template. Dispatch by node.type instead.
<template #node="{ node, checked, dragging }">
<UserNode
v-if="node.type === 'user'"
:node="node"
:checked="checked"
:dragging="dragging"
/>
<OrgNode
v-else-if="node.type === 'org'"
:node="node"
:checked="checked"
:dragging="dragging"
/>
<DefaultNode
v-else
:node="node"
:checked="checked"
:dragging="dragging"
/>
</template>
Benefits:
- Node types and visual templates map one-to-one, which reduces maintenance cost.
node.datacan keep clear business meaning without many temporary visual-only fields.- Nodes of the same type can reuse local components, tests, and styles.
7. Custom Expand Button
The default expand button is shown only when conditions are met:
| Condition | Current behavior |
|---|---|
node.expandHolderPosition is set and is not hide |
Shows the expand button. |
node.expandHolderPosition is not set, options.defaultExpandHolderPosition is not hide, and node.rgChildrenSize > 0 |
Shows the expand button. |
Position is hide |
Hides the expand button. |
Available positions from the current style implementation:
| Value | Description |
|---|---|
left |
Display on the left side of the node. |
right |
Display on the right side of the node. |
top |
Display above the node. |
bottom |
Display below the node. |
hide |
Hide the expand button. |
Vue example:
<template #node-expand-button="{ node, expandOrCollapseNode, expandHolderPosition }">
<button
class="my-expand rg-events-all"
:class="'pos-' + expandHolderPosition"
@click.stop="expandOrCollapseNode"
>
{{ node.expanded === false ? '+' : '-' }}
</button>
</template>
React example:
<RelationGraph
options={graphOptions}
initialData={graphData}
nodeExpandButtonSlot={({ node, expandOrCollapseNode, expandHolderPosition }) => (
<button
className={`my-expand pos-${expandHolderPosition} rg-events-all`}
onMouseDown={(e) => e.stopPropagation()}
onClick={expandOrCollapseNode}
>
{node.expanded === false ? '+' : '-'}
</button>
)}
/>
Note: expandOrCollapseNode already calls the internal expand/collapse logic. You usually do not need to directly modify node.expanded.
8. Styling Recommendations
Node slot appearance is usually decided by three layers:
| Source | Example | Effect |
|---|---|---|
| Node data | node.color, node.borderColor, node.width |
Written to outer CSS variables, helping the main graph and some internal views stay consistent. |
| Global options | defaultNodeColor, defaultNodeBorderWidth |
Defaults used when node fields are not set. |
| Slot CSS | .user-node .avatar, .user-node .badge |
Complex content layout and business decoration. |
Recommended CSS:
.user-node {
min-width: 160px;
min-height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
box-sizing: border-box;
background: var(--rg-node-color);
color: var(--rg-node-font-color);
border: var(--rg-node-border-width) solid var(--rg-node-border-color);
border-radius: var(--rg-node-border-radius);
font-size: var(--rg-node-font-size);
}
.user-node.is-selected {
box-shadow: 0 0 0 2px #3b82f6;
}
.user-node.is-dragging {
opacity: 0.75;
}
.user-node .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
Notes:
- If node data already sets
color,borderColor,borderWidth, and similar fields, usingvar(--rg-node-*)in slot CSS keeps data semantics and visuals consistent. - If you hard-code background/border entirely in slot CSS, the main view can look correct, but minimap, export, performance mode, or other internal rendering may not express the same visual semantics.
- For fixed-size nodes, set
node.width/node.heightor explicit CSS size to avoid sudden size changes after asynchronous image loading.
9. Interactive Controls Inside Nodes
The outer .rg-node listens to mousedown / touchstart and starts node dragging. Buttons, inputs, and menus inside nodes need to stop event propagation if they should not trigger dragging.
Vue:
<button class="rg-events-all" @mousedown.stop @click.stop="openPanel(node)">
Edit
</button>
React:
<button
className="rg-events-all"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
openPanel(node);
}}
>
Edit
</button>
Svelte:
<button
class="rg-events-all"
on:mousedown|stopPropagation
on:touchstart|stopPropagation
on:click|stopPropagation={() => openPanel(node)}
>
Edit
</button>
If a control also needs drag, input, scroll, or similar events, check whether its layer is affected by pointer-events. Node slots are usually directly interactive; canvas and view layers need events enabled separately.
10. FAQ
Why does setting left/top inside #node not work?
Node positioning is handled by the outer .rg-node-peel with transform: translate(node.x, node.y). #node only controls content inside the node, so it should usually not position the whole node itself.
Why cannot my custom node be dragged?
Common causes are that slot content covers the outer node event, or an internal element stops mousedown/touchstart propagation. Do not casually call stopPropagation on ordinary content; only do it for interactive controls such as buttons, inputs, and menus.
Why is the actual line connection position inaccurate?
Node size is synchronized to runtime through DOM measurement. Asynchronous images, font loading, and collapsed content expansion can change size. Recommendations:
- Set fixed width/height for images.
- Set stable
width/heightfor complex nodes. - When necessary after data changes, call instance view/layout update APIs.
What should be put in node.data?
Put business fields there, such as user avatar, status, group, permission, metrics, and external ID. Do not put graph structure/state fields such as x/y, selected, or expanded into node.data.
Can I completely bypass the default node shell?
Not with the regular #node slot. It only replaces content inside .rg-node. This preserves node dragging, selection, size measurement, connection point calculation, and internal state. If you need fully custom rendering, you usually need to customize graph internals at a higher layer or modify the source.