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.