JavaScript is required

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.data can 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, using var(--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.height or 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/height for 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.

11. Next Reading