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.
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.
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.
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.
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.
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;