Whiteboard Features

React Flow is designed for building node-based UIs like workflow editors, flowcharts and diagrams. Even if React Flow is not made for creating whiteboard applications, you might want to integrate common whiteboard features. These examples show how to add drawing capabilities to your applications when you need to annotate or sketch alongside your nodes and edges.

Examples

✏️ Freehand draw (Pro)

Draw smooth curves on your React Flow pane. Useful for annotations or sketching around existing nodes.

Features:

  • Mouse/touch drawing
  • Adjustable brush size and color
  • converts drawn paths into custom nodes

Common uses:

  • Annotating flowcharts
  • Adding notes to diagrams
  • Sketching ideas around nodes

! THIS IS A PRO EXAMPLE. SUBSCRIBE TO https://reactflow.dev/pro TO ACCESS PRO EXAMPLES !

🎯 Lasso selection

Select multiple elements by drawing a freeform selection area with an option to include partially selected elements.

Features:

  • Freeform selection shapes
  • partial selection of elements

Common uses:

  • Selecting nodes and annotations together
  • Complex selections in mixed content

Example: examples/whiteboard/lasso-selection

App.jsx
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  useNodesState,
  useEdgesState,
  addEdge,
  Controls,
  Background,
  Panel,
} from '@xyflow/react';
import { Lasso } from './Lasso';
import './index.css';
 
const initialNodes = [
  {
    id: '1',
    position: { x: 0, y: 0 },
    data: { label: 'Hello' },
  },
  {
    id: '2',
    position: { x: 300, y: 0 },
    data: { label: 'World' },
  },
];
 
const initialEdges = [];
 
export default function LassoSelectionFlow() {
  const [nodes, _, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const onConnect = useCallback((params) => setEdges((els) => addEdge(params, els)), []);
 
  const [partial, setPartial] = useState(false);
  const [isLassoActive, setIsLassoActive] = useState(true);
 
  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnect={onConnect}
      fitView
      colorMode="system"
    >
      <Controls />
      <Background />
      {isLassoActive && <Lasso partial={partial} />}
 
      <Panel position="top-left" className="lasso-controls">
        <div className="xy-theme__button-group">
          <button
            className={`xy-theme__button ${isLassoActive ? 'active' : ''}`}
            onClick={() => setIsLassoActive(true)}
          >
            Lasso Mode
          </button>
          <button
            className={`xy-theme__button ${!isLassoActive ? 'active' : ''}`}
            onClick={() => setIsLassoActive(false)}
          >
            Selection Mode
          </button>
        </div>
 
        <label>
          <input
            type="checkbox"
            checked={partial}
            onChange={() => setPartial((p) => !p)}
            className="xy-theme__checkbox"
          />
          Partial selection
        </label>
      </Panel>
    </ReactFlow>
  );
}
Lasso.tsx
import { useRef, type PointerEvent } from 'react';
import { useReactFlow, useStore } from '@xyflow/react';
 
import { getSvgPathFromStroke } from './utils';
 
type NodePoints = ([number, number] | [number, number, number])[];
type NodePointObject = Record<string, NodePoints>;
 
export function Lasso({ partial }: { partial: boolean }) {
  const { flowToScreenPosition, setNodes } = useReactFlow();
  const { width, height, nodeLookup } = useStore((state) => ({
    width: state.width,
    height: state.height,
    nodeLookup: state.nodeLookup,
  }));
  const canvas = useRef<HTMLCanvasElement>(null);
  const ctx = useRef<CanvasRenderingContext2D | undefined | null>(null);
 
  const nodePoints = useRef<NodePointObject>({});
  const pointRef = useRef<[number, number][]>([]);
 
  function handlePointerDown(e: PointerEvent) {
    (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
    const points = pointRef.current;
 
    const nextPoints = [...points, [e.pageX, e.pageY]] satisfies [number, number][];
    pointRef.current = nextPoints;
 
    nodePoints.current = {};
    for (const node of nodeLookup.values()) {
      const { x, y } = node.internals.positionAbsolute;
      const { width = 0, height = 0 } = node.measured;
      const points = [
        [x, y],
        [x + width, y],
        [x + width, y + height],
        [x, y + height],
      ] satisfies NodePoints;
      nodePoints.current[node.id] = points;
    }
 
    ctx.current = canvas.current?.getContext('2d');
    if (!ctx.current) return;
    ctx.current.lineWidth = 1;
    ctx.current.fillStyle = 'rgba(0, 89, 220, 0.08)';
    ctx.current.strokeStyle = 'rgba(0, 89, 220, 0.8)';
  }
 
  function handlePointerMove(e: PointerEvent) {
    if (e.buttons !== 1) return;
    const points = pointRef.current;
    const nextPoints = [...points, [e.pageX, e.pageY]] satisfies [number, number][];
    pointRef.current = nextPoints;
 
    const path = new Path2D(getSvgPathFromStroke(nextPoints));
 
    if (!ctx.current) return;
    ctx.current.clearRect(0, 0, width, height);
    ctx.current.fill(path);
    ctx.current.stroke(path);
 
    const nodesToSelect = new Set<string>();
 
    for (const [nodeId, points] of Object.entries(nodePoints.current)) {
      if (partial) {
        // Partial selection: select node if any point is in the path
        for (const point of points) {
          const { x, y } = flowToScreenPosition({ x: point[0], y: point[1] });
          if (ctx.current.isPointInPath(path, x, y)) {
            nodesToSelect.add(nodeId);
            break;
          }
        }
      } else {
        // Full selection: select node only if all points are in the path
        let allPointsInPath = true;
        for (const point of points) {
          const { x, y } = flowToScreenPosition({ x: point[0], y: point[1] });
          if (!ctx.current.isPointInPath(path, x, y)) {
            allPointsInPath = false;
            break;
          }
        }
        if (allPointsInPath) {
          nodesToSelect.add(nodeId);
        }
      }
    }
 
    setNodes((nodes) =>
      nodes.map((node) => ({
        ...node,
        selected: nodesToSelect.has(node.id),
      })),
    );
  }
 
  function handlePointerUp(e: PointerEvent) {
    (e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
    pointRef.current = [];
    if (ctx.current) {
      ctx.current.clearRect(0, 0, width, height);
    }
  }
 
  return (
    <canvas
      ref={canvas}
      width={width}
      height={height}
      className="tool-overlay"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
    />
  );
}
index.css
@import url('@xyflow/react/dist/style.css');
/* we put the theme css at the end to override some of the default css variables and styles */
@import url('./xy-theme.css');
 
html,
body {
  margin: 0;
  font-family: sans-serif;
  box-sizing: border-box;
}
 
#app {
  width: 100vw;
  height: 100vh;
}
 
.tool-overlay {
  pointer-events: auto;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 4;
  height: 100%;
  width: 100%;
  transform-origin: top left;
  touch-action: none;
}
 
.lasso-controls {
  display: flex;
  align-items: center;
  gap: 10px;
}
 
.lasso-controls button {
  width: 150px;
}
 
.lasso-controls label {
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  color: #111827;
}
 
.dark .lasso-controls label {
  color: #f3f4f6;
}
utils.ts
import getStroke from 'perfect-freehand';
 
export const pathOptions = {
  size: 7,
  thinning: 0.5,
  smoothing: 0.5,
  streamline: 0.5,
  easing: (t: number) => t,
  start: {
    taper: 0,
    easing: (t: number) => t,
    cap: true,
  },
  end: {
    taper: 0.1,
    easing: (t: number) => t,
    cap: true,
  },
};
 
export function getSvgPathFromStroke(stroke: number[][]) {
  if (!stroke.length) return '';
 
  const d = stroke.reduce(
    (acc, [x0, y0], i, arr) => {
      const [x1, y1] = arr[(i + 1) % arr.length];
      acc.push(x0, y0, ',', (x0 + x1) / 2, (y0 + y1) / 2);
      return acc;
    },
    ['M', ...stroke[0], 'Q'],
  );
 
  d.push('Z');
  return d.join(' ');
}
 
export function pointsToPath(points: [number, number, number][], zoom = 1) {
  const stroke = getStroke(points, {
    ...pathOptions,
    size: pathOptions.size * zoom,
  });
  return getSvgPathFromStroke(stroke);
}
🧹 Eraser

Remove items by “erasing” over them. Uses collision detection to determine what to delete.

Features:

  • Collision-based erasing
  • Visual eraser cursor

Common uses:

  • Removing parts of a flow

Example: examples/whiteboard/eraser

App.jsx
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  useNodesState,
  useEdgesState,
  addEdge,
  Controls,
  Background,
  Panel,
} from '@xyflow/react';
import './index.css';
 
import { ErasableNode } from './ErasableNode';
import { ErasableEdge } from './ErasableEdge';
import { Eraser } from './Eraser';
 
 
const initialNodes = [
  {
    id: '1',
    type: 'erasable-node',
    position: { x: 0, y: 0 },
    data: { label: 'Hello' },
  },
  {
    id: '2',
    type: 'erasable-node',
    position: { x: 300, y: 0 },
    data: { label: 'World' },
  },
];
 
const initialEdges = [
  {
    id: '1->2',
    type: 'erasable-edge',
    source: '1',
    target: '2',
  },
];
 
const nodeTypes = {
  'erasable-node': ErasableNode,
};
 
const edgeTypes = {
  'erasable-edge': ErasableEdge,
};
 
const defaultEdgeOptions = {
  type: 'erasable-edge',
};
 
export default function EraserFlow() {
  const [nodes, _, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const onConnect = useCallback((params) => setEdges((els) => addEdge(params, els)), []);
 
  const [isEraserActive, setIsEraserActive] = useState(true);
 
  return (
    <ReactFlow
      nodes={nodes}
      nodeTypes={nodeTypes}
      edges={edges}
      edgeTypes={edgeTypes}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnect={onConnect}
      fitView
      defaultEdgeOptions={defaultEdgeOptions}
      colorMode="system"
    >
      <Controls />
      <Background />
 
      {isEraserActive && <Eraser />}
 
      <Panel position="top-left">
        <div className="xy-theme__button-group">
          <button
            className={`xy-theme__button ${isEraserActive ? 'active' : ''}`}
            onClick={() => setIsEraserActive(true)}
          >
            Eraser Mode
          </button>
          <button
            className={`xy-theme__button ${!isEraserActive ? 'active' : ''}`}
            onClick={() => setIsEraserActive(false)}
          >
            Selection Mode
          </button>
        </div>
      </Panel>
    </ReactFlow>
  );
}
ErasableEdge.tsx
import {
  BaseEdge,
  type EdgeProps,
  type Edge,
  getSmoothStepPath,
  useInternalNode,
} from '@xyflow/react';
import { ErasableNodeType } from './ErasableNode';
 
export type ErasableEdgeType = Edge<{ toBeDeleted?: boolean }, 'erasable-edge'>;
 
export function ErasableEdge({
  id,
  source,
  sourceX,
  sourceY,
  target,
  targetX,
  targetY,
  data,
}: EdgeProps<ErasableEdgeType>) {
  const [edgePath] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY });
 
  const sourceNode = useInternalNode<ErasableNodeType>(source);
  const targetNode = useInternalNode<ErasableNodeType>(target);
 
  const toBeDeleted =
    data?.toBeDeleted || sourceNode?.data.toBeDeleted || targetNode?.data.toBeDeleted;
 
  return <BaseEdge id={id} path={edgePath} style={{ opacity: toBeDeleted ? 0.3 : 1 }} />;
}
ErasableNode.tsx
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
 
export type ErasableNodeType = Node<
  { toBeDeleted?: boolean; label?: string },
  'erasable-node'
>;
 
export function ErasableNode({
  data: { label, toBeDeleted },
}: NodeProps<ErasableNodeType>) {
  return (
    <div style={{ opacity: toBeDeleted ? 0.3 : 1 }}>
      <Handle type="target" position={Position.Top} />
      <div style={{ padding: 10 }}>{label}</div>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}
Eraser.tsx
import { useRef, useEffect, type PointerEvent } from 'react';
import {
  useEdges,
  useNodes,
  useReactFlow,
  useStore,
  type ReactFlowState,
} from '@xyflow/react';
import getStroke from 'perfect-freehand';
 
import { polylineIntersectsRectangle, pathsIntersect } from './utils';
import { ErasableNodeType } from './ErasableNode';
import { ErasableEdgeType } from './ErasableEdge';
 
// Type definitions for path coordinates
// - can be 2D or 3D points (with pressure) for freehand strokes for instance
type PathPoints = ([number, number] | [number, number, number])[];
 
type IntersectionData = {
  id: string;
  type?: string;
  points?: PathPoints;
  rect?: { x: number; y: number; width: number; height: number };
};
 
type TimestampedPoint = {
  point: [number, number];
  timestamp: number;
};
 
// Threshold distance for detecting intersections between paths
const intersectionThreshold = 5;
 
// Distance between points to sample for edge intersection detection
// This is a trade-off between performance and accuracy
const sampleDistance = 150;
 
const pathOptions = {
  size: Math.max(10, intersectionThreshold),
  thinning: 0.5,
  smoothing: 0.5,
  streamline: 0.5,
  easing: (t: number) => t,
  start: {
    taper: true,
  },
  end: {
    taper: 0,
  },
};
 
const storeSelector = (state: ReactFlowState) => ({
  width: state.width,
  height: state.height,
});
 
/**
 * Eraser component that provides an overlay canvas for erasing nodes and edges.
 * Draws a visual trail and detects intersections with flow elements to mark them for deletion.
 */
export function Eraser() {
  const { width, height } = useStore(storeSelector);
  const { screenToFlowPosition, deleteElements, getInternalNode, setNodes, setEdges } =
    useReactFlow<ErasableNodeType, ErasableEdgeType>();
  const nodes = useNodes<ErasableNodeType>();
  const edges = useEdges<ErasableEdgeType>();
 
  const canvas = useRef<HTMLCanvasElement | null>(null);
  const ctx = useRef<CanvasRenderingContext2D | undefined | null>();
 
  // Cached intersection data for performance during dragging
  const nodeIntersectionData = useRef<IntersectionData[]>([]);
  const edgeIntersectionData = useRef<IntersectionData[]>([]);
 
  const trailPoints = useRef<TimestampedPoint[]>([]);
  const animationFrame = useRef<number>(0);
  const isDrawing = useRef<boolean>(false);
 
  useEffect(() => {
    return () => {
      if (animationFrame.current) {
        cancelAnimationFrame(animationFrame.current);
      }
    };
  }, []);
 
  function handlePointerDown(e: PointerEvent<HTMLCanvasElement>) {
    (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
 
    isDrawing.current = true;
    trailPoints.current = [
      {
        point: [e.pageX, e.pageY],
        timestamp: Date.now(),
      },
    ];
 
    nodeIntersectionData.current = [];
    for (const node of nodes) {
      const internalNode = getInternalNode(node.id);
      if (!internalNode) continue;
 
      const { x, y } = internalNode.internals.positionAbsolute;
      const { width = 0, height = 0 } = internalNode.measured;
 
      nodeIntersectionData.current.push({
        id: node.id,
        type: node.type,
        rect: { x, y, width, height },
      });
    }
 
    edgeIntersectionData.current = [];
 
    for (const edge of edges) {
      const path = document.querySelector<SVGPathElement>(
        `.react-flow__edge[data-id="${edge.id}"] path`,
      );
 
      if (!path) continue;
 
      const length = path.getTotalLength();
      const steps = length / Math.max(10, length / sampleDistance);
      const points: [number, number][] = [];
 
      for (let i = 0; i <= length + steps; i += steps) {
        const point = path.getPointAtLength(i);
        points.push([point.x, point.y]);
      }
 
      edgeIntersectionData.current.push({
        id: edge.id,
        type: edge.type,
        points,
      });
    }
 
    ctx.current = canvas.current?.getContext('2d');
    if (!ctx.current) return;
    ctx.current.lineWidth = 1;
 
    if (animationFrame.current) {
      cancelAnimationFrame(animationFrame.current);
    }
    animate();
  }
 
  function handlePointerMove(e: PointerEvent) {
    if (e.buttons !== 1) return;
 
    trailPoints.current.push({
      point: [e.pageX, e.pageY],
      timestamp: Date.now(),
    });
 
    const points = trailPoints.current.map((tp) => tp.point);
 
    if (!ctx.current || points.length < 2) return;
 
    const flowPoints = points.map(([x, y]) => {
      const flowPos = screenToFlowPosition({ x, y });
      return [flowPos.x, flowPos.y] as [number, number];
    });
 
    const nodesToDelete = new Set<string>();
    const edgesToDelete = new Set<string>();
 
    for (const nodeInfo of nodeIntersectionData.current) {
      let intersects = false;
 
      if (nodeInfo.type === 'freehand' && nodeInfo.points) {
        intersects = pathsIntersect(
          flowPoints,
          nodeInfo.points as [number, number][],
          intersectionThreshold,
        );
      } else if (nodeInfo.rect) {
        intersects = polylineIntersectsRectangle(flowPoints, nodeInfo.rect);
      }
 
      if (intersects) {
        nodesToDelete.add(nodeInfo.id);
      }
    }
 
    for (const edgeInfo of edgeIntersectionData.current) {
      let intersects = false;
 
      if (edgeInfo.points) {
        intersects = pathsIntersect(
          flowPoints,
          edgeInfo.points as [number, number][],
          intersectionThreshold,
        );
      }
 
      if (intersects) {
        edgesToDelete.add(edgeInfo.id);
      }
    }
 
    setNodes((nodes: ErasableNodeType[]) =>
      nodes.map((node) => {
        if (nodesToDelete.has(node.id)) {
          return {
            ...node,
            data: {
              ...node.data,
              toBeDeleted: true,
            },
          };
        }
        return node;
      }),
    );
 
    setEdges((edges: ErasableEdgeType[]) =>
      edges.map((edge) => {
        if (edgesToDelete.has(edge.id)) {
          return {
            ...edge,
            data: {
              ...edge.data,
              toBeDeleted: true,
            },
          };
        }
        return edge;
      }),
    );
  }
 
  function handlePointerUp(e: PointerEvent) {
    (e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
 
    deleteElements({
      nodes: nodes.filter((node) => node.data.toBeDeleted),
      edges: edges.filter((edge) => edge.data?.toBeDeleted),
    });
 
    trailPoints.current = [];
    isDrawing.current = false;
 
    if (!animationFrame.current) {
      animate();
    }
  }
 
  function drawTrail() {
    if (!ctx.current || !canvas.current) return;
 
    ctx.current.clearRect(0, 0, canvas.current.width, canvas.current.height);
 
    if (trailPoints.current.length < 2) return;
 
    const strokePoints: [number, number, number][] = trailPoints.current.map(
      ({ point }) => [point[0], point[1], 0.5],
    );
 
    const stroke = getStroke(strokePoints, pathOptions);
 
    if (stroke.length < 2) return;
 
    ctx.current.fillStyle = '#ef4444';
    ctx.current.globalAlpha = 0.6;
    ctx.current.beginPath();
 
    stroke.forEach(([x, y], i) => {
      if (i === 0) {
        ctx.current!.moveTo(x, y);
      } else {
        ctx.current!.lineTo(x, y);
      }
    });
 
    ctx.current.closePath();
    ctx.current.fill();
    ctx.current.globalAlpha = 1.0;
  }
 
  function removeOldPoints() {
    const now = Date.now();
    const cutoffTime = now - 100;
 
    trailPoints.current = trailPoints.current.filter((tp) => tp.timestamp > cutoffTime);
  }
 
  function animate() {
    removeOldPoints();
    drawTrail();
 
    if (isDrawing.current || trailPoints.current.length > 0) {
      animationFrame.current = requestAnimationFrame(animate);
    }
  }
 
  return (
    <canvas
      ref={canvas}
      width={width}
      height={height}
      className="nopan nodrag tool-overlay"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
    />
  );
}
index.css
@import url('@xyflow/react/dist/style.css');
/* we put the theme css at the end to override some of the default css variables and styles */
@import url('./xy-theme.css');
 
html,
body {
  margin: 0;
  font-family: sans-serif;
  box-sizing: border-box;
}
 
#app {
  width: 100vw;
  height: 100vh;
}
 
.tool-overlay {
  pointer-events: auto;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 4;
  height: 100%;
  width: 100%;
  transform-origin: top left;
  cursor: crosshair;
  touch-action: none;
}
utils.ts
// Utility functions for geometric intersection detection
 
// Type definitions for better type safety
type Point = [number, number];
type Rectangle = { x: number; y: number; width: number; height: number };
 
// Check if two line segments intersect
function lineSegmentsIntersect(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
  const [x1, y1] = p1;
  const [x2, y2] = p2;
  const [x3, y3] = p3;
  const [x4, y4] = p4;
 
  const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
  if (Math.abs(denom) < 1e-10) return false; // Lines are parallel
 
  const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
  const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
 
  return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
 
// Check if a point is inside a rectangle
function pointInRectangle(point: Point, rect: Rectangle): boolean {
  const [x, y] = point;
  return (
    x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
  );
}
 
// Get the four edges of a rectangle as line segments
function getRectangleEdges(rect: Rectangle): [Point, Point][] {
  const { x, y, width, height } = rect;
  return [
    [
      [x, y],
      [x + width, y],
    ], // top edge
    [
      [x + width, y],
      [x + width, y + height],
    ], // right edge
    [
      [x + width, y + height],
      [x, y + height],
    ], // bottom edge
    [
      [x, y + height],
      [x, y],
    ], // left edge
  ];
}
 
// Check if a polyline (series of connected line segments) intersects with a rectangle
export function polylineIntersectsRectangle(points: Point[], rect: Rectangle): boolean {
  if (points.length < 2) return false;
 
  // Early return if any point is inside the rectangle
  for (const point of points) {
    if (pointInRectangle(point, rect)) {
      return true;
    }
  }
 
  // Check if any line segment intersects with rectangle edges
  const rectEdges = getRectangleEdges(rect);
 
  for (let i = 0; i < points.length - 1; i++) {
    const lineStart = points[i];
    const lineEnd = points[i + 1];
 
    for (const [edgeStart, edgeEnd] of rectEdges) {
      if (lineSegmentsIntersect(lineStart, lineEnd, edgeStart, edgeEnd)) {
        return true;
      }
    }
  }
 
  return false;
}
 
// Calculate distance between two points
function distanceBetweenPoints(p1: Point, p2: Point): number {
  const [x1, y1] = p1;
  const [x2, y2] = p2;
  return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
 
// Calculate the closest point on a line segment to a given point
function closestPointOnSegment(
  point: Point,
  segmentStart: Point,
  segmentEnd: Point,
): Point {
  const [px, py] = point;
  const [x1, y1] = segmentStart;
  const [x2, y2] = segmentEnd;
 
  const dx = x2 - x1;
  const dy = y2 - y1;
  const lengthSquared = dx * dx + dy * dy;
 
  if (lengthSquared === 0) return segmentStart; // Segment is a point
 
  const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared));
  return [x1 + t * dx, y1 + t * dy];
}
 
// Check if two paths intersect using a more efficient approach
export function pathsIntersect(
  path1: Point[],
  path2: Point[],
  threshold: number = 1,
): boolean {
  if (path1.length < 2 || path2.length < 2) return false;
 
  // First, do the more precise line segment intersection check
  for (let i = 0; i < path1.length - 1; i++) {
    for (let j = 0; j < path2.length - 1; j++) {
      if (lineSegmentsIntersect(path1[i], path1[i + 1], path2[j], path2[j + 1])) {
        return true;
      }
    }
  }
 
  // If no exact intersection, check for proximity based on threshold
  if (threshold > 0) {
    for (let i = 0; i < path1.length - 1; i++) {
      const segment1Start = path1[i];
      const segment1End = path1[i + 1];
 
      for (let j = 0; j < path2.length - 1; j++) {
        const segment2Start = path2[j];
        const segment2End = path2[j + 1];
 
        // Check distance between segment endpoints and closest points
        const distances = [
          distanceBetweenPoints(
            segment1Start,
            closestPointOnSegment(segment1Start, segment2Start, segment2End),
          ),
          distanceBetweenPoints(
            segment1End,
            closestPointOnSegment(segment1End, segment2Start, segment2End),
          ),
          distanceBetweenPoints(
            segment2Start,
            closestPointOnSegment(segment2Start, segment1Start, segment1End),
          ),
          distanceBetweenPoints(
            segment2End,
            closestPointOnSegment(segment2End, segment1Start, segment1End),
          ),
        ];
 
        if (Math.min(...distances) <= threshold) {
          return true;
        }
      }
    }
  }
 
  return false;
}
 
// Simplified path sampling for cases where you need discrete points
export function samplePathPoints(points: Point[], maxDistance: number = 5): Point[] {
  if (points.length < 2) return [...points];
 
  const result: Point[] = [points[0]];
 
  for (let i = 1; i < points.length; i++) {
    const prev = result[result.length - 1];
    const current = points[i];
    const distance = distanceBetweenPoints(prev, current);
 
    if (distance > maxDistance) {
      // Add intermediate points
      const numSegments = Math.ceil(distance / maxDistance);
      for (let j = 1; j < numSegments; j++) {
        const t = j / numSegments;
        const interpolated: Point = [
          prev[0] + (current[0] - prev[0]) * t,
          prev[1] + (current[1] - prev[1]) * t,
        ];
        result.push(interpolated);
      }
    }
 
    result.push(current);
  }
 
  return result;
}
📐 Rectangle draw

Create rectangular shapes by clicking and dragging. Good for highlighting areas or creating backgrounds for node groups.

Features:

  • Click-and-drag rectangle creation
  • Customizable colors

Common uses:

  • Creating background containers
  • Grouping related nodes visually
  • Highlighting sections of diagrams

Example: examples/whiteboard/rectangle

App.jsx
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  useNodesState,
  useEdgesState,
  addEdge,
  Controls,
  Background,
  Panel,
} from '@xyflow/react';
import './index.css';
 
import { RectangleNode } from './RectangleNode';
import { RectangleTool } from './RectangleTool';
 
 
const initialNodes = [
  {
    id: '1',
    type: 'rectangle',
    position: { x: 250, y: 5 },
    data: { color: '#ff7000' },
    width: 150,
    height: 100,
  },
];
const initialEdges = [];
 
const nodeTypes = {
  rectangle: RectangleNode,
};
 
export default function RectangleFlow() {
  const [nodes, _, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const onConnect = useCallback((params) => setEdges((els) => addEdge(params, els)), []);
 
  const [isRectangleActive, setIsRectangleActive] = useState(true);
 
  return (
    <ReactFlow
      nodes={nodes}
      nodeTypes={nodeTypes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnect={onConnect}
      fitView
      colorMode="system"
    >
      <Controls />
      <Background />
 
      {isRectangleActive && <RectangleTool />}
 
      <Panel position="top-left">
        <div className="xy-theme__button-group">
          <button
            className={`xy-theme__button ${isRectangleActive ? 'active' : ''}`}
            onClick={() => setIsRectangleActive(true)}
          >
            Rectangle Mode
          </button>
          <button
            className={`xy-theme__button ${!isRectangleActive ? 'active' : ''}`}
            onClick={() => setIsRectangleActive(false)}
          >
            Selection Mode
          </button>
        </div>
      </Panel>
    </ReactFlow>
  );
}
RectangleNode.tsx
import {
  NodeResizer,
  NodeToolbar,
  type Node,
  type NodeProps,
  useReactFlow,
  useOnSelectionChange,
} from '@xyflow/react';
import { useCallback, useState } from 'react';
 
export type RectangleNodeType = Node<{ color: string }, 'rectangle'>;
 
const colorOptions = [
  '#f5efe9', // very light warm grey
  '#ef4444', // red
  '#f97316', // orange
  '#eab308', // yellow
  '#22c55e', // green
  '#3b82f6', // blue
  '#8b5cf6', // purple
  '#ec4899', // pink
  '#64748b', // gray
];
 
const styles = {
  toolbar: {
    display: 'flex',
    gap: '0.25rem',
    borderRadius: '0.5rem',
    border: '1px solid #e5e5e5',
    backgroundColor: 'white',
    padding: '0.5rem',
    boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
  },
  colorButton: {
    height: '1.5rem',
    width: '1.5rem',
    borderRadius: '9999px',
    border: 'none',
    cursor: 'pointer',
    transition: 'transform 0.15s ease-in-out',
  },
  colorButtonHover: {
    transform: 'scale(1.1)',
  },
  outerContainer: {
    display: 'flex',
    height: '100%',
    width: '100%',
    alignItems: 'center',
    justifyContent: 'center',
  },
  innerContainer: {
    position: 'relative' as const,
    height: 'calc(100% - 5px)',
    width: 'calc(100% - 5px)',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'white',
    borderRadius: '0.375rem',
    boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
    border: '1px solid #e5e5e5',
  },
  innerContainerSelected: {
    outline: '2px solid #3b82f6',
    outlineOffset: '2px',
  },
} as const;
 
export function RectangleNode({
  id,
  selected,
  dragging,
  data: { color },
}: NodeProps<RectangleNodeType>) {
  const { updateNodeData } = useReactFlow();
 
  const [multipleNodesSelected, setMultipleNodesSelected] = useState(false);
 
  const onSelectionChange = useCallback(
    ({ nodes }: { nodes: Node[] }) => {
      if (nodes.length > 1) {
        setMultipleNodesSelected(true);
      } else {
        setMultipleNodesSelected(false);
      }
    },
    [setMultipleNodesSelected],
  );
 
  useOnSelectionChange({ onChange: onSelectionChange });
 
  const handleColorChange = (newColor: string) => {
    updateNodeData(id, { color: newColor });
  };
 
  return (
    <>
      <NodeResizer isVisible={selected && !dragging} />
      <NodeToolbar
        isVisible={selected && !dragging && !multipleNodesSelected}
        className="nopan"
      >
        <div style={styles.toolbar}>
          {colorOptions.map((colorOption) => (
            <button
              key={colorOption}
              onClick={() => handleColorChange(colorOption)}
              style={{
                ...styles.colorButton,
                backgroundColor: colorOption,
              }}
              title={`Set color to ${colorOption}`}
            />
          ))}
        </div>
      </NodeToolbar>
      <div style={styles.outerContainer}>
        <div
          style={{
            ...styles.innerContainer,
            backgroundColor: color,
            ...(selected ? styles.innerContainerSelected : {}),
          }}
        ></div>
      </div>
    </>
  );
}
RectangleTool.tsx
import { useState, type PointerEvent } from 'react';
import { useReactFlow, type XYPosition } from '@xyflow/react';
 
function getPosition(start: XYPosition, end: XYPosition) {
  return {
    x: Math.min(start.x, end.x),
    y: Math.min(start.y, end.y),
  };
}
 
function getDimensions(start: XYPosition, end: XYPosition, zoom: number = 1) {
  return {
    width: Math.abs(end.x - start.x) / zoom,
    height: Math.abs(end.y - start.y) / zoom,
  };
}
 
const colors = [
  '#D14D41',
  '#DA702C',
  '#D0A215',
  '#879A39',
  '#3AA99F',
  '#4385BE',
  '#8B7EC8',
  '#CE5D97',
];
 
function getRandomColor(): string {
  return colors[Math.floor(Math.random() * colors.length)];
}
 
export function RectangleTool() {
  const [start, setStart] = useState<XYPosition | null>(null);
  const [end, setEnd] = useState<XYPosition | null>(null);
 
  const { screenToFlowPosition, getViewport, setNodes } = useReactFlow();
 
  function handlePointerDown(e: PointerEvent) {
    (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
    setStart({ x: e.pageX, y: e.pageY });
  }
 
  function handlePointerMove(e: PointerEvent) {
    if (e.buttons !== 1) return;
    setEnd({ x: e.pageX, y: e.pageY });
  }
 
  function handlePointerUp() {
    if (!start || !end) return;
    const position = screenToFlowPosition(getPosition(start, end));
    const dimension = getDimensions(start, end, getViewport().zoom);
 
    setNodes((nodes) => [
      ...nodes,
      {
        id: crypto.randomUUID(),
        type: 'rectangle',
        position,
        ...dimension,
        data: {
          color: getRandomColor(),
        },
      },
    ]);
 
    setStart(null);
    setEnd(null);
  }
 
  const rect =
    start && end
      ? {
          position: getPosition(start, end),
          dimension: getDimensions(start, end),
        }
      : null;
 
  return (
    <div
      className="nopan nodrag tool-overlay"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
    >
      {rect && (
        <div
          className="rectangle-preview"
          style={{
            ...rect.dimension,
            transform: `translate(${rect.position.x}px, ${rect.position.y}px)`,
            border: '2px dashed rgba(0, 89, 220, 0.8)',
            pointerEvents: 'none',
          }}
        ></div>
      )}
    </div>
  );
}
index.css
@import url('@xyflow/react/dist/style.css');
/* we put the theme css at the end to override some of the default css variables and styles */
@import url('./xy-theme.css');
 
html,
body {
  margin: 0;
  font-family: sans-serif;
  box-sizing: border-box;
}
 
#app {
  width: 100vw;
  height: 100vh;
}
 
.react-flow__node {
  padding: 0;
  border: none;
}
 
.tool-overlay {
  pointer-events: auto;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 4;
  height: 100%;
  width: 100%;
  transform-origin: top left;
  cursor: copy;
  touch-action: none;
}
 
.rectangle-preview {
  position: absolute;
  z-index: 10;
}

Whiteboard libraries

If you are looking for a more complete whiteboard solution, consider using libraries that are specifically designed for whiteboard applications like tldraw or Excalidraw. These libraries provide a full set of features for collaborative drawing, shapes, text, and more.