Every drawing tool is the same three events
The mouse handlers don't change. The geometry per shape does. Once you see the rhythm, every new tool is a discriminated-union variant and a switch case.
A canvas is mostly a stage that fires events
The first thing that surprised me building a drawing canvas in React is how little of the work happens inside any single tool. The stage doesn't draw anything. Konva's Stage is a dumb surface that turns user input into events. A Zustand store knows which tool is currently selected. Local React state holds two things: the shapes that already exist, and at most one shape currently being drawn. The drawing logic, the part that feels like the product, is just a small handler attached to three events.
Once I saw that, the whole thing collapsed into a pattern.
The three-phase rhythm
Every shape-creating tool listens to the same three events on the stage:
function onMouseDown(e: KonvaEventObject<MouseEvent>) { /* start */ }
function onMouseMove(e: KonvaEventObject<MouseEvent>) { /* preview */ }
function onMouseUp(e: KonvaEventObject<MouseEvent>) { /* commit */ }
The phases line up with what the user is doing:
- Mouse down → start. Read the pointer position. Create a draft shape in state, seeded with that position, the current
activeColor, andstrokeWidthfrom the store. Flip anisDrawingflag on. - Mouse move → preview. While
isDrawingis true, read the new pointer position and update the draft's geometry. TheLayerre-renders, so the user sees the shape grow under their cursor. - Mouse up → commit. Push the draft into the permanent
shapesarray, clear the draft, flipisDrawingoff.
At any moment the layer is rendering [...committedShapes, draftShape]. The draft is the same kind of thing as a committed shape, just younger.
How the active tool branches the logic
In onMouseDown, you read activeTool from the store and decide what kind of draft to create. In onMouseMove, you update that draft according to its kind. The phases are universal. The geometry per phase is what differs.
function onMouseDown() {
const point = stage.getPointerPosition();
if (!point) return;
switch (store.activeTool) {
case "rectangle":
case "ellipse":
case "triangle":
setDraft({ kind: store.activeTool, start: point, end: point });
break;
case "line":
case "arrow":
setDraft({ kind: store.activeTool, from: point, to: point });
break;
case "pen":
setDraft({ kind: "pen", points: [point] });
break;
case "text":
// handled separately, see below
break;
}
setIsDrawing(true);
}
Four families of geometry fall out of this.
Bounding-box shapes (rectangle, ellipse, triangle)
Store the start point at mousedown. On every mousemove, compute width and height from currentPointer − start. Two opposite corners fully define the shape.
function onMouseMove() {
if (!isDrawing) return;
const point = stage.getPointerPosition();
if (!point) return;
setDraft((d) => d && { ...d, end: point });
}
// at render time
const width = draft.end.x - draft.start.x;
const height = draft.end.y - draft.start.y;
Negative width or height is fine: it just means the user is dragging up or to the left. Konva renders it correctly either way, and on commit you can normalize to positive values if your downstream code prefers that.
Two-endpoint shapes (line, arrow)
Same idea conceptually, but you keep the points instead of deriving width and height. The arrow is just a line with a triangle head at the second endpoint. Konva ships an Arrow primitive that does the head for you.
function onMouseMove() {
if (!isDrawing) return;
const point = stage.getPointerPosition();
if (!point) return;
setDraft((d) => d && { ...d, to: point });
}
Freehand path (pen)
Start an array of points on mousedown. On every mousemove, append the new pointer to that array. Konva's Line with tension set draws a smoothed curve through them.
function onMouseMove() {
if (!isDrawing) return;
const point = stage.getPointerPosition();
if (!point) return;
setDraft((d) => d && { ...d, points: [...d.points, point] });
}
A small Konva detail: Line wants its points as a flat [x1, y1, x2, y2, …] array, not an array of {x, y} objects. Keep the structured form in your model, then flatten at render time. The model stays readable and the renderer stays fast.
This is also the only family where geometry grows instead of being replaced. Every move adds a point; you never overwrite. That detail matters once you start thinking about undo, throttling, and serialization.
Text
The odd one out. Mousedown places an empty text node at the pointer and switches it into edit mode by overlaying an HTML <input> on top of the canvas at that position. There's no drag phase. The text shape commits when the input loses focus or the user presses enter.
function onMouseDown() {
if (store.activeTool !== "text") return; // not text — let the normal flow handle it
const point = stage.getPointerPosition();
if (!point) return;
const id = crypto.randomUUID();
setShapes((s) => [...s, { id, kind: "text", at: point, value: "" }]);
setEditingId(id);
}
The trick that makes this feel native is positioning the HTML input precisely over the canvas using the same coordinate system Konva uses, so the user cannot tell the difference between editing on the canvas and editing in a textbox.
The two tools that break the pattern
Two tools do not fit the three-phase rhythm because they do not create shapes. They modify the world.
Select. Mousedown checks whether the click hit an existing shape (Konva tells you via e.target). If it did, that shape becomes the selected one. Attach a Transformer node to it for resize and rotate handles, and dragging the shape itself moves it. Mouseup does nothing special.
function onMouseDown(e: KonvaEventObject<MouseEvent>) {
if (store.activeTool !== "select") return;
if (e.target === stage) {
setSelectedId(null); // clicked empty space, deselect
return;
}
setSelectedId(e.target.id());
}
Eraser. Two flavors, depending on what feels right for your product:
- Pixel-style eraser. While the mouse is down, on each move, hit-test the pointer against existing shapes and remove any that intersect. Continuous removal.
- Object eraser. On click only. The shape under the cursor is deleted. Simpler, more predictable, less satisfying.
Pick one and stick with it. Some apps offer both as separate tools, but mixing them in one cursor is confusing.
Why this rhythm is worth keeping
Once the three phases are codified, adding a new shape is mostly a question of "what does the draft look like, and how does it grow during mousemove?" You write a discriminated-union variant, a case in mousedown, a case in mousemove, and one more branch in your <Shape> renderer. No new event handlers, no new state machine, no new rules.
That's the architecture every serious drawing tool converges on. Figma does it. tldraw does it. Excalidraw does it. The reason isn't taste; it's that any other architecture eventually breaks down once you have eight tools and three users asking for a ninth.
Build the canvas as a dumb stage. Codify the rhythm. Branch only the geometry. The features arrive from the seam between the rhythm and the geometry, not from new event plumbing.