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

reactflow-custom-nodes

SKILL.md

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

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

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

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

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

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

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

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

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

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

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)

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

/* 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

Weekly Installs
11
First Seen
8 days ago
Installed on
gemini-cli11
github-copilot11
codex11
opencode10
kimi-cli10
amp10