Implementing Canvas Hit Region Detection and Drag-to-Select Functionality with React

March 9, 2025

This blog post demonstrates how to implement hit region detection and drag (marquee) selection on an HTML Canvas, and how to integrate these features with React.

result

Implementing Hit Region Detection

Implementing hit region detection (a.k.a. click event listeners) is a bit trickier with Canvas elements compared to SVG elements. This is because, unlike SVG elements where you can directly attach a click event listener to a DOM element, Canvas elements don’t support event listeners on individual drawn shapes.

Therefore, to implement hit region detection, we need to calculate whether the click event’s coordinates fall within the bounds of a shape.

Starting with a React application, we set the parent div of the Canvas to 80% of the current window’s width and height. This makes it easier to demonstrate the process of converting viewport coordinates to actual Canvas coordinates.

import React from 'react';

function App() {
  return (
    <div style={{ width: '100vw', height: '100vh', display: 'grid', placeItems: 'center' }} >
      <div style={{ width: '80%', height: '80%', border: '1px solid pink' }} >
        <canvas style={{ width: '100%', height: '100%' }} />
      </div>
    </div>
  );
}

export default App;

This code will render the Canvas container on the screen, with the Canvas element fully occupying the container.

blank

Next, we need to generate some nodes to draw on the Canvas. We create a custom hook that returns both the generated nodes and a state update callback, which can later be used to modify the nodes’ state.

When generating node coordinates, we need to ensure that the coordinates fall within the Canvas element. To achieve this, we use the getBoundingClientRect API to retrieve the Canvas’s position in the current viewport and calculate the coordinates of a safe rectangular area, ensuring all nodes are generated within bounds.

interface Node {
	id: number;
	x: number;
	y: number;
	radius: number;
	isHighlighted: boolean;
}

function useNodes(canvasRef: React.RefObject<HTMLCanvasElement | null>) {
	const [nodes, setNodes] = React.useState<Node[]>([]);

	React.useEffect(() => {
		if (!canvasRef.current) return;

		const canvas = canvasRef.current;
		canvas.width = canvas.clientWidth;
		canvas.height = canvas.clientHeight;

		// get canvas position relative to viewport
		const rect = canvas.getBoundingClientRect();

		// calculate safe area for circles, to not overlap with container border 
		const radius = 25;
		const safeArea = {
			x: rect.left + radius,
			y: rect.top + radius,
			width: rect.width - radius * 2,
			height: rect.height - radius * 2,
		};

		// generate circles based on canvas dimensions and position
		setNodes(
			Array.from({ length: 10 }, (_, i) => ({
				id: i + 1,
				x: Math.random() * safeArea.width + safeArea.x,
				y: Math.random() * safeArea.height + safeArea.y,
				radius,
				isHighlighted: false,
			}))
		);
	}, []);

	return { nodes, setNodes };
}

To make the Canvas drawing side-effect logic clearer and easier to reuse, we also encapsulate the drawing logic inside a custom hook.

function useDrawCanvas(
  canvasRef: React.RefObject<HTMLCanvasElement | null>,
  nodes: Node[]
) {
  React.useEffect(() => {
    if (!canvasRef.current || nodes.length === 0) return;

    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
    if (!context) return;

    // clear canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    // draw circles and their IDs
    nodes.forEach((node) => {
      // Convert viewport coordinates to canvas coordinates
      const canvasX = node.x - canvas.getBoundingClientRect().left;
      const canvasY = node.y - canvas.getBoundingClientRect().top;

      // draw circle
      context.beginPath();
      context.arc(canvasX, canvasY, node.radius, 0, 2 * Math.PI);
      context.strokeStyle = node.isHighlighted ? '#ff6900' : '#f6339a';
      context.lineWidth = 3;
      context.stroke();

      // draw ID text
      context.font = '600 16px Monospace';
      context.fillStyle = node.isHighlighted ? '#ff6900' : '#f6339a';
      context.textAlign = 'center';
      context.textBaseline = 'middle';
      context.fillText(node.id.toString(), canvasX, canvasY);
    });
  }, [nodes]);
}

In our App component, we first obtain a reference to the Canvas element and pass it to the useNodes hook to generate the nodes. We then pass both the Canvas reference and the generated nodes to the useDrawCanvas hook to render the nodes.

function App() {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  
  const { nodes, setNodes } = useNodes(canvasRef); // generate nodes

  useDrawCanvas(canvasRef, nodes); // draw canvas

  return (
     <div style={{ width: '100vw', height: '100vh', display: 'grid', placeItems: 'center' }} >
      <div style={{ width: '80%', height: '80%', border: '1px solid pink' }} >
        <canvas 
          ref={canvasRef}
          style={{ width: '100%', height: '100%' }}
        />
      </div>
    </div> 
  );
}

At this point, we should be able to see the nodes drawn on the Canvas, with their IDs displayed.

nodes

To implement hit region detection and highlight a node when clicked, we capture the click event’s coordinates from the browser window and convert them to coordinates relative to the Canvas element. We then iterate through all the nodes to check whether the transformed coordinates fall within a node’s radius.

Here, we use the onMouseUp event instead of the onClick event on the Canvas element to make it easier to implement drag selection later.

function App() {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const { nodes, setNodes } = useNodes(canvasRef);

  useDrawCanvas(canvasRef, nodes);

  const handleMouseUp = (event: React.MouseEvent<HTMLCanvasElement>) => {
    if (!canvasRef.current) return;

    const canvas = canvasRef.current;
    const rect = canvas.getBoundingClientRect();
    const endX = event.clientX - rect.left;
    const endY = event.clientY - rect.top;

    // handle click highlighting
    setNodes((prevNodes) =>
      prevNodes.map((node) => {
        const nodeCanvasX = node.x - rect.left;
        const nodeCanvasY = node.y - rect.top;

        // calculate distance from click to node center
        const distance = Math.sqrt(
          Math.pow(endX - nodeCanvasX, 2) + Math.pow(endY - nodeCanvasY, 2)
        );

        // if click is within node radius, toggle highlight
        if (distance <= node.radius) {
          return {
            ...node,
            isHighlighted: !node.isHighlighted,
          };
        }
        return node;
      })
    );
  };

  return (
    <div style={{ width: '100vw', height: '100vh', display: 'grid', placeItems: 'center' }} >
      <div style={{ width: '80%', height: '80%', border: '1px solid pink' }} >
        <canvas
          onMouseUp={handleMouseUp} 
          ref={canvasRef}
          style={{ width: '100%', height: '100%' }}
        />
      </div>
    </div> 
  );
}

The user should now be able to click a node to highlight or unhighlight it.

click

Implementing Drag-to-Select

As mentioned in the previous section, we use the onMouseUp event instead of the onClick event to simplify the implementation of drag-to-select functionality. This is because dragging on the Canvas involves a mousedown followed by a mouseup, which would otherwise trigger a click event. Therefore, we need to manually determine whether the user is performing a drag selection or a simple click.

We track the user’s starting mousedown position using a mousedown event listener, and check the cursor’s ending position on a mouseup event. If the distance between the start and end positions is less than 5 pixels, we treat it as a click; otherwise, we recognize it as a drag-to-select action.

// use a ref to track the starting position
const dragStartRef = React.useRef<{ x: number; y: number } | null>(null);

const handleMouseDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
    //...
    
    // store the initial position of mouse down in a ref
    dragStartRef.current = { x: startX, y: startY };
    
    //... 
};

const handleMouseUp = (event: React.MouseEvent<HTMLCanvasElement>) => {
    //...
    
    // check if this was a click (no significant movement) or a drag
    const dx = endX - dragStartRef.current.x;
    const dy = endY - dragStartRef.current.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    const isClick = distance < 5; // 5 pixel threshold for click detection

    if (isClick) {
        // Handle click highlighting
        //...
    } else {
        // Handle drag selection
        // ...
    }

    // reset ref
    dragStartRef.current= null;
};

The next step is to draw a rectangle when a drag-to-select event is detected. Since the coordinates of the selection rectangle need to update continuously as the mouse moves, we use the onMouseMove event to track and update the rectangle’s coordinates in real time.

To determine whether a node is selected, we iterate through all the nodes and check whether each node falls within the selection rectangle’s area. If a node is inside the rectangle, we highlight it.

interface SelectionRect {
	startX: number;
	startY: number;
	endX: number;
	endY: number;
}

function App() {
	const canvasRef = React.useRef<HTMLCanvasElement>(null);
	const { nodes, setNodes } = useNodes(canvasRef);

	const [selectionRect, setSelectionRect] =
		React.useState<SelectionRect | null>(null);
	const dragStartRef = React.useRef<{ x: number; y: number } | null>(null);

	useDrawCanvas(canvasRef, nodes, selectionRect);

	const handleMouseDown = React.useCallback(
		(event: React.MouseEvent<HTMLCanvasElement>) => {
			if (!canvasRef.current) return;

			const canvas = canvasRef.current;
			const rect = canvas.getBoundingClientRect();
			const startX = event.clientX - rect.left;
			const startY = event.clientY - rect.top;

			dragStartRef.current = { x: startX, y: startY };
			setSelectionRect({
				startX,
				startY,
				endX: startX,
				endY: startY,
			});
		},
		[]
	);

	const handleMouseMove = React.useCallback(
		(event: React.MouseEvent<HTMLCanvasElement>) => {
			if (!canvasRef.current || !selectionRect) return;

			const canvas = canvasRef.current;
			const rect = canvas.getBoundingClientRect();
			const currentX = event.clientX - rect.left;
			const currentY = event.clientY - rect.top;

			setSelectionRect((prev) =>
				prev
					? {
							...prev,
							endX: currentX,
							endY: currentY,
					  }
					: null
			);
		},
		[selectionRect]
	);

	const handleMouseUp = React.useCallback(
		(event: React.MouseEvent<HTMLCanvasElement>) => {
			if (!canvasRef.current || !selectionRect || !dragStartRef.current)
				return;

			const canvas = canvasRef.current;
			const rect = canvas.getBoundingClientRect();
			const endX = event.clientX - rect.left;
			const endY = event.clientY - rect.top;

			// Check if this was a click (no significant movement) or a drag
			const dx = endX - dragStartRef.current.x;
			const dy = endY - dragStartRef.current.y;
			const distance = Math.sqrt(dx * dx + dy * dy);
			const isClick = distance < 5; // 5 pixel threshold for click detection

			if (isClick) {
				// Handle click highlighting
				setNodes((prevNodes)=>
					prevNodes.map((node)=> {
						const nodeCanvasX= node.x - rect.left;
						const nodeCanvasY= node.y - rect.top;

						// Calculate distance from click to node center
						const distance= Math.sqrt(
							Math.pow(endX - nodeCanvasX, 2) +
								Math.pow(endY - nodeCanvasY, 2)
						);

						// If click is within node radius, toggle highlight
						if (distance <= node.radius) {
							return {
								...node,
								isHighlighted: !node.isHighlighted,
							};
						}
						return node;
					})
				);
			} else {
				// Handle drag selection
				const left= Math.min(selectionRect.startX, selectionRect.endX);
				const right= Math.max(
					selectionRect.startX,
					selectionRect.endX
				);
				const top= Math.min(selectionRect.startY, selectionRect.endY);
				const bottom= Math.max(
					selectionRect.startY,
					selectionRect.endY
				);

				setNodes((prevNodes)=>
					prevNodes.map((node)=> {
						const nodeCanvasX= node.x - rect.left;
						const nodeCanvasY= node.y - rect.top;

						// Check if node is within selection rectangle
						const isInSelection=
							nodeCanvasX >= left &&
							nodeCanvasX <= right &&
							nodeCanvasY >= top &&
							nodeCanvasY <= bottom;

						return { ...node, isHighlighted: isInSelection };
					})
				);
			}

			setSelectionRect(null);
			dragStartRef.current= null;
		},
		[selectionRect, setNodes]
	);

	return (
		<div
			style={{
				width: "100vw",
				height: "100vh",
				display: "grid",
				placeItems: "center",
			}}
		>
			<div
				style={{
					width: "80%",
					height: "80%",
					border: "1px solid pink",
				}}
			>
				<canvas
					ref={canvasRef}
					onMouseDown={handleMouseDown}
					onMouseMove={handleMouseMove}
					onMouseUp={handleMouseUp}
					onMouseLeave={handleMouseUp}
					style={{
						width: "100%",
						height: "100%",
					}}
				/>
			</div>
		</div>
	);
}

We also need to update the logic in useDrawCanvas to render the selection box on the screen while the user performs a selection.

function useDrawCanvas(
	canvasRef: React.RefObject<HTMLCanvasElement | null>,
	nodes: Node[],
	selectionRect: SelectionRect | null
) {
	React.useEffect(() => {
		if (!canvasRef.current || nodes.length === 0) return;

		const canvas = canvasRef.current;
		const context = canvas.getContext("2d");
		if (!context) return;

		// Clear canvas
		context.clearRect(0, 0, canvas.width, canvas.height);

		// Draw selection rectangle if dragging
		if (selectionRect) {
			context.beginPath();
			context.rect(
				selectionRect.startX,
				selectionRect.startY,
				selectionRect.endX - selectionRect.startX,
				selectionRect.endY - selectionRect.startY
			);
			context.strokeStyle = "#666";
			context.lineWidth = 1;
			context.stroke();
			context.fillStyle = "rgba(0, 0, 0, 0.1)";
			context.fill();
		}

		// Draw circles and their IDs
		nodes.forEach((node) => {
			// Convert viewport coordinates to canvas coordinates
			const canvasX = node.x - canvas.getBoundingClientRect().left;
			const canvasY = node.y - canvas.getBoundingClientRect().top;

			// Draw circle
			context.beginPath();
			context.arc(canvasX, canvasY, node.radius, 0, 2 * Math.PI);
			context.strokeStyle = node.isHighlighted ? "#ff6900" : "#f6339a";
			context.lineWidth = 3;
			context.stroke();

			// Draw ID text
			context.font = "600 16px Monospace";
			context.fillStyle = node.isHighlighted ? "#ff6900" : "#f6339a";
			context.textAlign = "center";
			context.textBaseline = "middle";
			context.fillText(node.id.toString(), canvasX, canvasY);
		});
	}, [nodes, selectionRect]);
}

The user should now be able to perform both drag-to-select and click-to-highlight interactions on the Canvas.

drag-to-select

The final code is as follows and can also be found on StackBlitz.

import React from 'react';

interface Node {
  id: number;
  x: number;
  y: number;
  radius: number;
  isHighlighted: boolean;
}

interface SelectionRect {
  startX: number;
  startY: number;
  endX: number;
  endY: number;
}

function useNodes(canvasRef: React.RefObject<HTMLCanvasElement | null>) {
  const [nodes, setNodes] = React.useState<Node[]>([]);

  React.useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = canvasRef.current;
    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;

    // get canvas position relative to viewport
    const rect = canvas.getBoundingClientRect();

    // calculate safe area for circles, to not overlap with container border
    const radius = 25;
    const safeArea = {
      x: rect.left + radius,
      y: rect.top + radius,
      width: rect.width - radius * 2,
      height: rect.height - radius * 2,
    };

    // generate circles based on canvas dimensions and position
    setNodes(
      Array.from({ length: 10 }, (_, i) => ({
        id: i + 1,
        x: Math.random() * safeArea.width + safeArea.x,
        y: Math.random() * safeArea.height + safeArea.y,
        radius,
        isHighlighted: false,
      }))
    );
  }, []);

  return { nodes, setNodes };
}

function useDrawCanvas(
  canvasRef: React.RefObject<HTMLCanvasElement | null>,
  nodes: Node[],
  selectionRect: SelectionRect | null
) {
  React.useEffect(() => {
    if (!canvasRef.current || nodes.length === 0) return;

    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
    if (!context) return;

    // clear canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    // draw selection rectangle if dragging
    if (selectionRect) {
      context.beginPath();
      context.rect(
        selectionRect.startX,
        selectionRect.startY,
        selectionRect.endX - selectionRect.startX,
        selectionRect.endY - selectionRect.startY
      );
      context.strokeStyle = '#666';
      context.lineWidth = 1;
      context.stroke();
      context.fillStyle = 'rgba(0, 0, 0, 0.1)';
      context.fill();
    }

    // draw circles and their IDs
    nodes.forEach((node) => {
      // convert viewport coordinates to canvas coordinates
      const canvasX = node.x - canvas.getBoundingClientRect().left;
      const canvasY = node.y - canvas.getBoundingClientRect().top;

      // draw circle
      context.beginPath();
      context.arc(canvasX, canvasY, node.radius, 0, 2 * Math.PI);
      context.strokeStyle = node.isHighlighted ? '#ff6900' : '#f6339a';
      context.lineWidth = 3;
      context.stroke();

      // draw ID text
      context.font = '600 16px Monospace';
      context.fillStyle = node.isHighlighted ? '#ff6900' : '#f6339a';
      context.textAlign = 'center';
      context.textBaseline = 'middle';
      context.fillText(node.id.toString(), canvasX, canvasY);
    });
  }, [nodes, selectionRect]);
}

function App() {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const { nodes, setNodes } = useNodes(canvasRef);

  const [selectionRect, setSelectionRect] =
    React.useState<SelectionRect | null>(null);
  const dragStartRef = React.useRef<{ x: number; y: number } | null>(null);

  useDrawCanvas(canvasRef, nodes, selectionRect);

  const handleMouseDown = React.useCallback(
    (event: React.MouseEvent<HTMLCanvasElement>) => {
      if (!canvasRef.current) return;

      const canvas = canvasRef.current;
      const rect = canvas.getBoundingClientRect();
      const startX = event.clientX - rect.left;
      const startY = event.clientY - rect.top;

      dragStartRef.current = { x: startX, y: startY };
      setSelectionRect({
        startX,
        startY,
        endX: startX,
        endY: startY,
      });
    },
    []
  );

  const handleMouseMove = React.useCallback(
    (event: React.MouseEvent<HTMLCanvasElement>) => {
      if (!canvasRef.current || !selectionRect) return;

      const canvas = canvasRef.current;
      const rect = canvas.getBoundingClientRect();
      const currentX = event.clientX - rect.left;
      const currentY = event.clientY - rect.top;

      setSelectionRect((prev) =>
        prev
          ? {
              ...prev,
              endX: currentX,
              endY: currentY,
            }
          : null
      );
    },
    [selectionRect]
  );

  const handleMouseUp = React.useCallback(
    (event: React.MouseEvent<HTMLCanvasElement>) => {
      if (!canvasRef.current || !selectionRect || !dragStartRef.current) return;

      const canvas = canvasRef.current;
      const rect = canvas.getBoundingClientRect();
      const endX = event.clientX - rect.left;
      const endY = event.clientY - rect.top;

      // check if this was a click (no significant movement) or a drag
      const dx = endX - dragStartRef.current.x;
      const dy = endY - dragStartRef.current.y;
      const distance = Math.sqrt(dx * dx + dy * dy);
      const isClick = distance < 5; // 5 pixel threshold for click detection

      if (isClick) {
        // handle click highlighting
        setNodes((prevNodes)=>
          prevNodes.map((node)=> {
            const nodeCanvasX= node.x - rect.left;
            const nodeCanvasY= node.y - rect.top;

            // calculate distance from click to node center
            const distance= Math.sqrt(
              Math.pow(endX - nodeCanvasX, 2) + Math.pow(endY - nodeCanvasY, 2)
            );

            // ff click is within node radius, toggle highlight
            if (distance <= node.radius) {
              return {
                ...node,
                isHighlighted: !node.isHighlighted,
              };
            }
            return node;
          })
        );
      } else {
        // handle drag selection
        const left= Math.min(selectionRect.startX, selectionRect.endX);
        const right= Math.max(selectionRect.startX, selectionRect.endX);
        const top= Math.min(selectionRect.startY, selectionRect.endY);
        const bottom= Math.max(selectionRect.startY, selectionRect.endY);

        setNodes((prevNodes)=>
          prevNodes.map((node)=> {
            const nodeCanvasX= node.x - rect.left;
            const nodeCanvasY= node.y - rect.top;

            // check if node is within selection rectangle
            const isInSelection=
              nodeCanvasX >= left &&
              nodeCanvasX <= right &&
              nodeCanvasY >= top &&
              nodeCanvasY <= bottom;

            return { ...node, isHighlighted: isInSelection };
          })
        );
      }

      setSelectionRect(null);
      dragStartRef.current= null;
    },
    [selectionRect, setNodes]
  );

  return (
    <div
      style={{
        width: '100vw',
        height: '100vh',
        display: 'grid',
        placeItems: 'center',
      }}
    >
      <div
        style={{
          width: '80%',
          height: '80%',
          border: '1px solid pink',
        }}
      >
        <canvas
          ref={canvasRef}
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          onMouseLeave={handleMouseUp}
          style={{
            width: '100%',
            height: '100%',
          }}
        />
      </div>
    </div>
  );
}

export default App;