home / skills / thebushidocollective / han / reactflow-custom-nodes

This skill helps you build and customize React Flow nodes and edges with reusable components, styling, and behaviors for complex editors.

npx playbooks add skill thebushidocollective/han --skill reactflow-custom-nodes

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
13.0 KB
---
name: reactflow-custom-nodes
user-invocable: false
description: Use when creating custom React Flow nodes, edges, and handles. Covers custom node components, resizable nodes, toolbars, and advanced customization.
allowed-tools:
  - Bash
  - Read
---

# React Flow Custom Nodes and Edges

Create fully customized nodes and edges with React Flow. Build complex
node-based editors with custom styling, behaviors, and interactions.

## Custom Node Component

```tsx
import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';

// Define custom node data type
type TextUpdaterNodeData = {
  label: string;
  onChange: (value: string) => void;
};

type TextUpdaterNode = Node<TextUpdaterNodeData>;

function TextUpdaterNode({ data, isConnectable }: NodeProps<TextUpdaterNode>) {
  const onChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
    data.onChange(evt.target.value);
  };

  return (
    <div className="text-updater-node">
      <Handle
        type="target"
        position={Position.Top}
        isConnectable={isConnectable}
      />
      <div>
        <label htmlFor="text">Text:</label>
        <input
          id="text"
          name="text"
          onChange={onChange}
          className="nodrag"
          defaultValue={data.label}
        />
      </div>
      <Handle
        type="source"
        position={Position.Bottom}
        id="a"
        isConnectable={isConnectable}
      />
    </div>
  );
}

// Memoize for performance
export default memo(TextUpdaterNode);
```

## Registering Custom Nodes

```tsx
import { ReactFlow } from '@xyflow/react';
import TextUpdaterNode from './TextUpdaterNode';
import ColorPickerNode from './ColorPickerNode';

// Define node types outside component to prevent re-renders
const nodeTypes = {
  textUpdater: TextUpdaterNode,
  colorPicker: ColorPickerNode,
};

function Flow() {
  const [nodes, setNodes, onNodesChange] = useNodesState([
    {
      id: '1',
      type: 'textUpdater',
      position: { x: 0, y: 0 },
      data: {
        label: 'Hello',
        onChange: (value) => console.log(value),
      },
    },
  ]);

  return (
    <ReactFlow
      nodes={nodes}
      nodeTypes={nodeTypes}
      onNodesChange={onNodesChange}
    />
  );
}
```

## Styled Node with Tailwind

```tsx
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';

type StatusNodeData = {
  label: string;
  status: 'pending' | 'running' | 'completed' | 'error';
};

const statusColors = {
  pending: 'bg-yellow-100 border-yellow-400',
  running: 'bg-blue-100 border-blue-400',
  completed: 'bg-green-100 border-green-400',
  error: 'bg-red-100 border-red-400',
};

const statusIcons = {
  pending: '⏳',
  running: '⚡',
  completed: '✅',
  error: '❌',
};

function StatusNode({ data }: NodeProps<Node<StatusNodeData>>) {
  return (
    <div
      className={`px-4 py-2 rounded-lg border-2 shadow-sm ${statusColors[data.status]}`}
    >
      <Handle type="target" position={Position.Top} className="!bg-gray-400" />

      <div className="flex items-center gap-2">
        <span className="text-xl">{statusIcons[data.status]}</span>
        <span className="font-medium">{data.label}</span>
      </div>

      <Handle
        type="source"
        position={Position.Bottom}
        className="!bg-gray-400"
      />
    </div>
  );
}

export default memo(StatusNode);
```

## Node with Multiple Handles

```tsx
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';

type SwitchNodeData = {
  label: string;
  cases: string[];
};

function SwitchNode({ data }: NodeProps<Node<SwitchNodeData>>) {
  return (
    <div className="switch-node bg-white rounded-lg shadow-lg p-3 min-w-[150px]">
      {/* Single input */}
      <Handle type="target" position={Position.Top} id="input" />

      <div className="font-bold text-center border-b pb-2 mb-2">
        {data.label}
      </div>

      {/* Multiple outputs - one per case */}
      <div className="space-y-2">
        {data.cases.map((caseLabel, index) => (
          <div key={index} className="relative text-sm text-right pr-4">
            {caseLabel}
            <Handle
              type="source"
              position={Position.Right}
              id={`case-${index}`}
              style={{ top: `${30 + index * 28}px` }}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

export default memo(SwitchNode);
```

## Resizable Node

```tsx
import { memo } from 'react';
import { Handle, Position, NodeResizer, type NodeProps } from '@xyflow/react';

type ResizableNodeData = {
  label: string;
  content: string;
};

function ResizableNode({ data, selected }: NodeProps<Node<ResizableNodeData>>) {
  return (
    <>
      <NodeResizer
        color="#ff0071"
        isVisible={selected}
        minWidth={100}
        minHeight={50}
        handleStyle={{ width: 8, height: 8 }}
      />
      <Handle type="target" position={Position.Top} />
      <div className="p-4 h-full">
        <div className="font-bold">{data.label}</div>
        <div className="text-sm text-gray-600">{data.content}</div>
      </div>
      <Handle type="source" position={Position.Bottom} />
    </>
  );
}

export default memo(ResizableNode);
```

## Node Toolbar

```tsx
import { memo, useState } from 'react';
import {
  Handle,
  Position,
  NodeToolbar,
  type NodeProps,
  useReactFlow,
} from '@xyflow/react';

type EditableNodeData = {
  label: string;
};

function EditableNode({
  id,
  data,
  selected,
}: NodeProps<Node<EditableNodeData>>) {
  const { setNodes, deleteElements } = useReactFlow();
  const [isEditing, setIsEditing] = useState(false);
  const [label, setLabel] = useState(data.label);

  const handleSave = () => {
    setNodes((nodes) =>
      nodes.map((node) =>
        node.id === id ? { ...node, data: { ...node.data, label } } : node
      )
    );
    setIsEditing(false);
  };

  const handleDelete = () => {
    deleteElements({ nodes: [{ id }] });
  };

  return (
    <>
      <NodeToolbar isVisible={selected} position={Position.Top}>
        <button onClick={() => setIsEditing(true)} className="toolbar-btn">
          ✏️ Edit
        </button>
        <button onClick={handleDelete} className="toolbar-btn text-red-500">
          🗑️ Delete
        </button>
      </NodeToolbar>

      <Handle type="target" position={Position.Top} />

      <div className="px-4 py-2 bg-white rounded shadow">
        {isEditing ? (
          <div className="flex gap-2">
            <input
              value={label}
              onChange={(e) => setLabel(e.target.value)}
              className="border rounded px-2"
              autoFocus
            />
            <button onClick={handleSave}>Save</button>
          </div>
        ) : (
          <span>{data.label}</span>
        )}
      </div>

      <Handle type="source" position={Position.Bottom} />
    </>
  );
}

export default memo(EditableNode);
```

## Custom Edge

```tsx
import { memo } from 'react';
import {
  BaseEdge,
  EdgeLabelRenderer,
  getBezierPath,
  useReactFlow,
  type EdgeProps,
} from '@xyflow/react';

function ButtonEdge({
  id,
  sourceX,
  sourceY,
  targetX,
  targetY,
  sourcePosition,
  targetPosition,
  style = {},
  markerEnd,
}: EdgeProps) {
  const { setEdges } = useReactFlow();

  const [edgePath, labelX, labelY] = getBezierPath({
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
  });

  const onEdgeClick = () => {
    setEdges((edges) => edges.filter((edge) => edge.id !== id));
  };

  return (
    <>
      <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
      <EdgeLabelRenderer>
        <div
          style={{
            position: 'absolute',
            transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
            fontSize: 12,
            pointerEvents: 'all',
          }}
          className="nodrag nopan"
        >
          <button
            className="w-5 h-5 bg-gray-200 rounded-full border border-gray-400 cursor-pointer hover:bg-red-200"
            onClick={onEdgeClick}
          >
            ×
          </button>
        </div>
      </EdgeLabelRenderer>
    </>
  );
}

export default memo(ButtonEdge);
```

## Edge with Custom Path

```tsx
import { memo } from 'react';
import { BaseEdge, getStraightPath, type EdgeProps } from '@xyflow/react';

function CustomPathEdge({
  sourceX,
  sourceY,
  targetX,
  targetY,
}: EdgeProps) {
  // Create a custom S-curve path
  const midY = (sourceY + targetY) / 2;

  const path = `
    M ${sourceX} ${sourceY}
    C ${sourceX} ${midY},
      ${targetX} ${midY},
      ${targetX} ${targetY}
  `;

  return <BaseEdge path={path} style={{ stroke: '#b1b1b7', strokeWidth: 2 }} />;
}

export default memo(CustomPathEdge);
```

## Registering Custom Edges

```tsx
import { ReactFlow } from '@xyflow/react';
import ButtonEdge from './ButtonEdge';
import CustomPathEdge from './CustomPathEdge';

const edgeTypes = {
  buttonEdge: ButtonEdge,
  customPath: CustomPathEdge,
};

function Flow() {
  const [edges, setEdges, onEdgesChange] = useEdgesState([
    {
      id: 'e1-2',
      source: '1',
      target: '2',
      type: 'buttonEdge',
    },
  ]);

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      edgeTypes={edgeTypes}
      onEdgesChange={onEdgesChange}
    />
  );
}
```

## Connection Validation

```tsx
import { useCallback } from 'react';
import { ReactFlow, type IsValidConnection } from '@xyflow/react';

function Flow() {
  // Validate connections before they're made
  const isValidConnection: IsValidConnection = useCallback(
    (connection) => {
      // Prevent self-connections
      if (connection.source === connection.target) {
        return false;
      }

      // Only allow connections between specific handle types
      const sourceNode = nodes.find((n) => n.id === connection.source);
      const targetNode = nodes.find((n) => n.id === connection.target);

      // Example: input nodes can't receive connections
      if (targetNode?.type === 'input') {
        return false;
      }

      // Example: output nodes can't send connections
      if (sourceNode?.type === 'output') {
        return false;
      }

      return true;
    },
    [nodes]
  );

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      isValidConnection={isValidConnection}
    />
  );
}
```

## Group Node (Parent Container)

```tsx
import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';

type GroupNodeData = {
  label: string;
};

function GroupNode({ data }: NodeProps<Node<GroupNodeData>>) {
  return (
    <div className="p-2 border-2 border-dashed border-gray-400 rounded-lg bg-gray-50/50 min-w-[200px] min-h-[150px]">
      <div className="text-xs text-gray-500 font-medium mb-2">{data.label}</div>
    </div>
  );
}

export default memo(GroupNode);

// Usage - child nodes reference parent
const nodes = [
  {
    id: 'group-1',
    type: 'group',
    data: { label: 'Group A' },
    position: { x: 0, y: 0 },
    style: { width: 300, height: 200 },
  },
  {
    id: 'child-1',
    data: { label: 'Child Node' },
    position: { x: 50, y: 50 },
    parentId: 'group-1',
    extent: 'parent',
  },
];
```

## CSS Styling

```css
/* node.css */
.react-flow__node-custom {
  background: white;
  border: 1px solid #1a192b;
  border-radius: 8px;
  padding: 10px;
  font-size: 12px;
  width: 150px;
}

.react-flow__node-custom.selected {
  border-color: #ff0071;
  box-shadow: 0 0 0 2px #ff0071;
}

.react-flow__handle {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #555;
}

.react-flow__handle-connecting {
  background-color: #ff0071;
}

.react-flow__handle-valid {
  background-color: #55dd99;
}

/* Prevent drag on interactive elements */
.nodrag {
  pointer-events: all;
}

/* Edge styling */
.react-flow__edge-path {
  stroke: #b1b1b7;
  stroke-width: 2;
}

.react-flow__edge.selected .react-flow__edge-path {
  stroke: #ff0071;
}

.react-flow__edge.animated .react-flow__edge-path {
  stroke-dasharray: 5;
  animation: dashdraw 0.5s linear infinite;
}

@keyframes dashdraw {
  from {
    stroke-dashoffset: 10;
  }
}
```

## When to Use This Skill

Use reactflow-custom-nodes when you need to:

- Build nodes with interactive form elements
- Create visually distinct node types
- Add node toolbars and context menus
- Build resizable nodes
- Create group/container nodes
- Add custom edge interactions
- Validate connections between nodes
- Build complex workflow interfaces

## Best Practices

- Always memoize custom node components with `memo()`
- Define nodeTypes/edgeTypes outside the component
- Use the `nodrag` class on interactive elements
- Keep node components focused and reusable
- Use TypeScript for type-safe node data
- Test connection validation thoroughly
- Consider accessibility in custom nodes
- Use CSS modules or Tailwind for styling

## Resources

- [Custom Nodes Guide](https://reactflow.dev/learn/customization/custom-nodes)
- [Custom Edges Guide](https://reactflow.dev/learn/customization/custom-edges)
- [Node Toolbar](https://reactflow.dev/api-reference/components/node-toolbar)
- [Node Resizer](https://reactflow.dev/api-reference/components/node-resizer)

Overview

This skill shows how to create custom React Flow nodes, edges, handles, and toolbars using TypeScript. It covers interactive node components, resizable nodes, group containers, custom edge rendering, and connection validation. The guidance focuses on practical patterns for performance, styling, and reusable node/edge registration.

How this skill works

Custom node components receive typed data and React Flow props (e.g., id, selected, isConnectable). Nodes expose Handle components for connection points; edges can use BaseEdge or custom SVG paths and render labels or controls via EdgeLabelRenderer. Nodes and edges are registered via nodeTypes and edgeTypes objects passed to ReactFlow, while helpers like NodeResizer and NodeToolbar add UI affordances. Connection validation runs before links are created to enforce rules (no self-links, restricted node types, etc.).

When to use it

  • Building editors with interactive inputs inside nodes (forms, pickers, editors)
  • Creating visually distinct node types with custom styling or icons
  • Adding inline node actions (edit, delete) via a NodeToolbar
  • Making nodes resizable or enforcing parent/child grouping
  • Implementing custom edge behaviors such as clickable buttons or non-standard paths
  • Enforcing connection rules with isValidConnection to prevent invalid links

Best practices

  • Memoize node and edge components with React.memo to reduce re-renders
  • Define nodeTypes and edgeTypes outside render scope to avoid recreating functions
  • Mark interactive elements with a `nodrag` class to prevent accidental canvas panning
  • Keep nodes focused and reusable: small components that accept typed data props
  • Use TypeScript for node/edge data shapes and compile-time safety
  • Test connection validation and accessibility for keyboard/ screen-reader users

Example use cases

  • A visual workflow editor with status nodes styled by Tailwind CSS and status icons
  • A conditional switch node that exposes multiple output handles, one per case
  • A resizable content node that users can resize with NodeResizer when selected
  • An editable node with a top toolbar offering inline edit and delete actions
  • Custom edges that render a small delete button on the path using EdgeLabelRenderer
  • A grouped canvas where child nodes are constrained inside a parent container (extent: 'parent')

FAQ

How do I prevent interactive inputs from dragging the canvas?

Add a `nodrag` class to inputs or interactive elements so React Flow ignores pointer events for dragging while letting the element receive events.

Where should I register node and edge types?

Define nodeTypes and edgeTypes outside your component render so they aren’t recreated on each render; then pass them into the ReactFlow component.