Sometimes the best projects are born from creative chaos and time limits.
I recently challenged myself to build an Excalidraw-style drawing tool — from scratch — in under 3 hours.
The result? Freehand — a fast, lightweight, and expressive tool for creating hand-drawn diagrams in the browser.
In this post, I’ll walk through how I built it, what tech powers it, and what I learned along the way.
What is Excalidraw?
Excalidraw is a popular open-source whiteboard tool that lets you draw diagrams with a hand-drawn look. It’s used by developers, designers, and educators to visualize ideas quickly, collaboratively, and with charm.
It features:
- 🖊️ Hand-drawn-style elements
- 🧠 Collaborative editing
- 🧰 Shapes, arrows, freehand, and text
- 📦 Export to PNG/SVG
- ⚡ Offline-first, local storage support
Excalidraw is simple, yet powerful — and the sketchy aesthetic makes it more human and fun.
What is Freehand?
Freehand is my attempt to reimagine the core ideas of Excalidraw — built in under 3 hours.
It’s not meant to replace it, but rather to explore how much you can build with the right tools, the right mindset, and a self-imposed time limit.
It lets you:
- Draw freehand lines ✏️
- Create shapes (rectangle, circle, etc.)
- Select, move, and delete elements
- Undo / Redo with keyboard shortcuts 🔄
- Save/Open drawings
- Export drawings as images
- Library that lets you save drawn items, and add them as you need
Libraries Used
Libraries that make the magic happen:
Zustand
Handles all the app's state—elements, selections, undo/redo, and more—without breaking a sweat.Rough.js
Draws shapes with that hand-drawn, wobbly look that makes everything feel playful and alive.perfect-freehand
Turns your mouse or stylus scribbles into beautiful, natural-looking freehand lines.
Architecture in a Nutshell
User Action --> Zustand State Update --> Canvas Re-render --> Rough.js + Path Render
The app maintains a elements[] array representing lines, rectangles, or freehand strokes. Drawing, editing, and deleting elements trigger a redraw of the entire canvas with new strokes.
Timeline Breakdown
5 mins - Setup: Vite + Canvas + Rough.js
30 mins - Shapes drawing engine
30 mins - Freehand pen stroke logic
30 mins - Element interaction (select/move/delete)
30 mins - Zustand refactor + Undo/Redo
30 mins - Polish UI & fix edge cases
Source code: https://github.com/tomlin7/Freehand
So HOW TO... BUILD IT?
- The heart of a this drawing app is the HTML canvas. We’ll use the HTML
<canvas>
element and handle mouse/touch events to draw.- Supports multiple shapes: rectangles, ellipses, diamonds, arrows, lines, pencil (freehand), text, and images.
- Integrates with roughjs for hand-drawn style rendering.
- Handles zoom, pan, and multi-layer drawing.
- Implements a local "library" for saving and reusing element groups. Exposes methods for exporting the canvas as an image and saving/loading as JSON.
Here is a simple minimal example:
import React, { useRef, useEffect, useState } from "react";
const Canvas: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawing, setDrawing] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
}, []);
const startDrawing = (e: React.MouseEvent) => {
setDrawing(true);
const ctx = canvasRef.current?.getContext("2d");
if (ctx) {
ctx.beginPath();
ctx.moveTo(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
}
};
const draw = (e: React.MouseEvent) => {
if (!drawing) return;
const ctx = canvasRef.current?.getContext("2d");
if (ctx) {
ctx.lineTo(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
ctx.stroke();
}
};
const stopDrawing = () => {
setDrawing(false);
};
return (
<canvas
ref={canvasRef}
className="border w-full h-full"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
);
};
export default Canvas;
Full version here: src/components/Canvas.tsx
- All drawing state is managed with Zustand (src/store/index.ts). The store tracks:
- elements: All drawn shapes and objects
- selectedElements: Currently selected items
- mode: Current tool (e.g., selection, rectangle, pencil)
- strokeColor, fillColor, strokeWidth, fillStyle, opacity: Style settings
- canvasBg: Canvas background color
- history: Undo/redo stack
The store exposes actions for adding, updating, selecting, deleting, duplicating, and reordering elements, as well as for undo/redo and style changes.
- A toolbar for tool selection + a Library src/components/Toolbar.tsx
- Tool Buttons: Switch between selection, shapes, pencil, text, and image tools.
- Tool Lock: Option to stay in the current tool after use.
- Library: Save the element to library, to reuse later.
- A sidebar that contains styling and layer controls src/components/Sidebar.tsx
- Stroke and Fill Color Pickers: Choose colors for outlines and fills.
- Fill Style: Solid, hachure (sketchy), or none.
- Stroke Width: Select line thickness.
- Opacity Slider: Adjust transparency.
- Layer Controls: Move elements up/down in the stack.
- Add a cool command palette for quick commands like open, save, export, reset, and theme switching. It’s accessible via keyboard shortcuts.
- Add keyboard shortcuts for picking tools easily, and managing elements - src/utils/keyboard.ts
- Enhance the canvas
- Undo/Redo: Managed via the store’s history stack.
- Layering: Bring-to-front/send-to-back actions.
- Export/Import: Download as PNG or JSON, load from file.
That's about it
There are still many features missing out in Freehand, and Excalidraw is still the GOAT. This challenge was only to see how hard it can be to build something like that.
There are multiple ways we can go about enhancing this tiny thing and creating something really cool though. Brainstorm and find out, and let me know!
Top comments (0)