Building a Custom Bezier Tool in Konva.js (React + Redux)
If you’ve ever tried building a Pen Tool (like in Photoshop, Illustrator, or Figma), you’ll know how challenging it can be.
Doing this inside Konva.js is not straightforward — handling Bezier curves, multiple path options, and state management requires careful design.
That’s why I’m sharing my implementation here — not as a polished final library, but as knowledge-sharing for anyone trying to implement a Pen/Bezier tool in a Konva.js + React + Redux stack.
Hopefully, this helps you avoid some of the pain points I went through.
⚡ Setup
Install dependencies:
npm install konva react-konva react-redux @reduxjs/toolkit react-icons
🏗️ Redux Slice (toolSlice.js)
Here’s how I structured my toolSlice to support Bezier options and control points:
// toolSlice.js
// ...Bezier option state...
initialState: {
// ...
bezierOption: "Straight Segments",
spiroPoints: [],
bsplinePoints: [],
controlPoints: [],
// ...
},
reducers: {
setBezierOption: (state, action) => {
state.bezierOption = action.payload;
},
addSpiroPoint: (state, action) => {
state.spiroPoints.push(action.payload);
},
addBSplinePoint: (state, action) => {
const point = action.payload;
if (point && typeof point.x === "number" && typeof point.y === "number") {
state.bsplinePoints.push(point);
} else {
console.error("Invalid point format in reducer:", point);
}
},
clearBSplinePoints: (state) => {
state.bsplinePoints = [];
},
addControlPoint: (state, action) => {
state.controlPoints.push(action.payload);
console.log("Updated controlPoints in Redux:", [...state.controlPoints]);
},
setControlPoints: (state, action) => {
console.log("Setting control points in Redux:", action.payload);
state.controlPoints = action.payload;
},
clearControlPoints: (state) => {
state.controlPoints = [];
},
// ...other bezier-related reducers...
addBezierPoint: (state, action) => {
const selectedLayer = state.layers[state.selectedLayerIndex];
const lastBezierShape = selectedLayer.shapes.find(
(shape) => shape.type === "Bezier"
);
if (lastBezierShape) {
lastBezierShape.points.push(...action.payload);
console.log("Updated Bezier points in Redux:", lastBezierShape.points);
} else {
selectedLayer.shapes.push({
id: `bezier-${Date.now()}`,
type: "Bezier",
points: action.payload,
strokeColor: state.strokeColor,
strokeWidth: 2,
});
console.log("New Bezier shape added:", action.payload);
}
},
clearBezier: (state) => {
state.shapes = state.shapes.filter((shape) => shape.type !== "Bezier");
},
}
export const {
setBezierOption,
addSpiroPoint,
addBSplinePoint,
clearBSplinePoints,
addControlPoint,
setControlPoints,
clearControlPoints,
addBezierPoint,
clearBezier,
} = toolSlice.actions;
🎨 Panel Component (Panel.jsx)
This is where I render the Bezier tool inside the Konva canvas:
// panel.jsx
// ...Bezier tool cursor icon...
const toolCursors = {
Bezier: <BsVectorPen size={20} color="black" />,
};
// ...Bezier option state...
const bezierOption = useSelector((state) => state.tool.bezierOption);
// ...Bezier drawing state...
const controlPoints = useSelector((state) => state.tool.controlPoints);
// ...Bezier path rendering...
{
selectedTool === "Bezier" && (
<Path
data={getBezierPath()}
stroke="black"
strokeWidth={2}
fill={isShapeClosed ? "rgba(0,0,0,0.1)" : "transparent"}
closed={isShapeClosed}
/>
)
}
// ...Bezier path generator...
const getBezierPath = () => {
if (controlPoints.length < 2) {
console.warn("Not enough control points to generate a path.");
return "";
}
const [start, ...rest] = controlPoints;
let path = `M ${start.x},${start.y}`;
for (let i = 0; i < rest.length; i++) {
const point = rest[i];
path += ` L ${point.x},${point.y}`;
}
if (isShapeClosed) {
path += ` Z`;
}
return path;
};
// ...Bezier path from points...
const getBezierPathFromPoints = (points, isClosed) => {
if (points.length < 1) return "";
const [start, ...rest] = points;
let path = `M ${start.x},${start.y}`;
for (let i = 0; i < rest.length; i++) {
const point = rest[i];
path += ` L ${point.x},${point.y}`;
}
if (isClosed) {
path += ` Z`;
}
return path;
};
// ...Bezier option select handler...
const handleOptionSelect = (option) => {
dispatch(setBezierOption(option));
dispatch(clearSpiroPoints());
dispatch(clearBSplinePoints());
dispatch(clearParaxialPoints());
dispatch(clearStraightPoints());
};
// ...Bezier path rendering for options...
{selectedTool === "Bezier" && (
bezierOption === "Straight Segments" ? (
straightPoints.length > 1 && (
<Path
data={`M ${straightPoints.map((p) => `${p.x},${p.y}`).join(" L ")}${isShapeClosed ? " Z" : ""}`}
stroke="black"
strokeWidth={2}
fill={isShapeClosed ? fillColor : "transparent"}
closed={isShapeClosed}
/>
)
) : bezierOption === "Spiro Path" ? (
renderSpiroPath()
) : bezierOption === "BSpline Path" ? (
renderBSplinePath()
) : bezierOption === "Paraxial Line Segments" ? (
renderParaxialSegments()
) : (
controlPoints.length > 1 && (
<Path
data={getBezierPath()}
stroke="black"
strokeWidth={2}
fill={isShapeClosed ? fillColor : "transparent"}
closed={isShapeClosed}
/>
)
)
)}
🖱️ Interaction Handlers
Dragging and closing paths:
// ...Bezier points update...
const handleDragMove = (e, index) => {
if (selectedTool === "Bezier" && bezierOption === "Spiro Path") {
const { x, y } = e.target.position();
dispatch(updateControlPoint({ index, point: { x, y } }));
}
if (selectedTool === "Bezier" && bezierOption === "BSpline Path") {
const { x, y } = e.target.position();
dispatch(updateControlPoint({ index, point: { x, y } }));
}
};
// ...Bezier double click handler...
const handleDoubleClick = () => {
if (newShape && newShape.type === "Bezier") {
dispatch(addShape(newShape));
setNewShape(null);
setIsDrawing(false);
}
if (selectedTool === "Bezier" && bezierOption === "Spiro Path" && spiroPoints.length > 1) {
const pathData = generateSpiroPath(spiroPoints, 0.5, isShapeClosed);
dispatch(
addShape({
id: `spiro-${Date.now()}`,
type: "Spiro Path",
path: pathData,
stroke: strokeColor,
strokeWidth: 2,
fill: fillColor || "black",
})
);
dispatch(clearSpiroPoints());
return;
}
if (selectedTool === "Bezier" && bezierOption === "BSpline Path" && bsplinePoints.length > 1) {
const pathData = generateBSplinePath(bsplinePoints, 0.5, true);
dispatch(
addShape({
id: `bspline-${Date.now()}`,
type: "Path",
path: pathData,
stroke: strokeColor,
strokeWidth: 2,
fill: isShapeClosed ? fillColor : "black",
})
);
dispatch(clearBSplinePoints());
setIsDrawing(false);
}
if (selectedTool === "Bezier" && bezierOption === "Paraxial Line Segments") {
if (paraxialPoints.length < 2) {
console.warn("Not enough points to create a shape.");
return;
}
let pathData = paraxialPoints
.map((point, index) => {
return index === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`;
})
.join(" ");
if (isShapeClosed) {
pathData += " Z";
}
dispatch(
addShape({
id: `paraxial-${Date.now()}`,
type: "Path",
path: pathData,
stroke: strokeColor,
strokeWidth: 2,
fill: isShapeClosed ? fillColor : "black",
})
);
dispatch(clearParaxialPoints());
setIsDrawing(false);
}
if (selectedTool === "Bezier" && bezierOption === "Straight Segments" && straightPoints.length > 1) {
let pathData = straightPoints
.map((point, index) => (index === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`))
.join(" ");
if (isShapeClosed) pathData += " Z";
dispatch(
addShape({
id: `straight-${Date.now()}`,
type: "Path",
path: pathData,
stroke: strokeColor,
strokeWidth: 2,
fill: isShapeClosed ? fillColor : "transparent",
})
);
dispatch(clearStraightPoints());
setIsDrawing(false);
setIsShapeClosed(false);
return;
}
};
## 🖼️ Rendering and Editing Bezier Shapes
Finally, when rendering shapes from the Redux store, I handle Bezier shapes separately so they can be selected, dragged, and transformed just like other shapes:
if (shape.type === "Bezier") {
const isSelected = selectedShapeIds.includes(shape.id);
return (
<React.Fragment key={shape.id}>
<Path
ref={(node) => {
if (node) shapeRefs.current[shape.id] = node;
else delete shapeRefs.current[shape.id];
console.log("Bezier ref for", shape.id, node);
}}
id={shape.id}
data={getBezierPathFromPoints(shape.points, shape.closed)}
stroke={isSelected ? "blue" : shape.stroke || shape.strokeColor || "black"}
strokeWidth={shape.strokeWidth || 2}
fill={shape.fill || (shape.closed ? shape.fillColor : "transparent")}
closed={shape.closed}
rotation={shape.rotation || 0}
draggable={
!shape.locked &&
selectedTool !== "Node" &&
selectedTool !== "Mesh" &&
selectedTool !== "Connector"
}
onDragMove={handleDragMove}
dash={getDashArray(shape.strokeStyle)}
onClick={(e) => {
e.cancelBubble = true;
if (shape.locked) return;
if (!selectedShapeIds.includes(shape.id)) {
dispatch(selectShape(shape.id));
}
}}
onMouseDown={(e) => {
e.cancelBubble = true;
}}
onTransformStart={(e) => {
e.cancelBubble = true;
}}
onTransformEnd={(e) => handleBezierTransformEnd(e, shape)}
onDragEnd={(e) => {
const { x, y } = e.target.position();
dispatch(updateShapePosition({ id: shape.id, x, y }));
}}
/>
{isSelected &&
!shape.locked &&
shapeRefs.current[shape.id] &&
selectedTool !== "Node" && (
<Transformer
ref={transformerRef}
nodes={selectedShapeIds
.map((id) => shapeRefs.current[id])
.filter(Boolean)}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
enabledAnchors={[
"top-left",
"top-center",
"top-right",
"middle-left",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
]}
skewEnabled={true}
/>
)}
</React.Fragment>
);
}
Flow Summary
[User Input]
|
|-- Click / Drag / Double-Click
v
[Panel Component]
|
|-- selectedTool === "Bezier" ?
|
+--> Yes → Read bezierOption & controlPoints from Redux
| |
| +--> Call appropriate path generator:
| |
| +--> Straight Segments → getStraightPath()
| |
| +--> Spiro Path → generateSpiroPath()
| |
| +--> BSpline Path → generateBSplinePath()
| |
| +--> Paraxial Segments → renderParaxialSegments()
| |
| +--> Default / Custom → getBezierPath()
|
+--> No → Ignore / other tools
v
[Interaction Handlers]
|
|-- handleDragMove → update controlPoints in Redux
|-- handleDoubleClick → generate pathData
|
|-- Dispatch addShape / clearPoints actions
v
[Redux Slice (toolSlice)]
|
|-- Stores:
| - controlPoints
| - straightPoints / spiroPoints / bsplinePoints / paraxialPoints
| - bezierOption
|-- Reducers update state based on dispatched actions
v
[Redux Store Updates]
|
|-- Triggers re-render in Panel
v
[Bezier Rendering]
|
|-- Uses <Path /> to draw shape
|-- stroke / fill / closed / draggable
|-- Calls getBezierPathFromPoints if rendering saved shapes
v
[Optional Transformer / Editing]
|
|-- Shows transformer if shape selected
|-- Drag / Scale / Rotate → dispatch updateShapePosition
v
[Canvas Updated / Shape Rendered]
✅ Conclusion
This setup lets me switch between Bezier variants and manage them cleanly in Redux.
It’s flexible enough to expand with more path types and keeps drawing logic decoupled from state.
Top comments (0)