webdevpuneet.com

Drawing Canvas HTML CSS JS — Free Drawing Board

Drawing Canvas · Tools · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<div class="app">
  <div class="toolbar">
    <div class="tool-group">
      <button class="tool-btn active" id="btn-brush" title="Brush">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
      </button>
      <button class="tool-btn" id="btn-eraser" title="Eraser">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16l10-10 7 7-3.5 3.5"/><path d="M6.0001 11L13 18"/></svg>
      </button>
      <button class="tool-btn" id="btn-line" title="Line">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/></svg>
      </button>
      <button class="tool-btn" id="btn-fill" title="Fill Bucket">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 11L8.93 1 3.62 6.31l5.1 5.1a2 2 0 0 0 0 2.83l5.66 5.66a2 2 0 0 0 2.83 0l1.79-1.79a2 2 0 0 0 0-2.83L19 11"/><path d="M20 17c0 0 2 1.5 2 2.5a2 2 0 0 1-4 0c0-1 2-2.5 2-2.5z"/></svg>
      </button>
    </div>
    <div class="tool-group">
      <input type="color" id="colorPicker" value="#ffffff" title="Color">
      <div class="size-group">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.5"><circle cx="12" cy="12" r="4"/></svg>
        <input type="range" id="sizeSlider" min="2" max="40" value="6" title="Brush Size">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>
      </div>
    </div>
    <div class="tool-group">
      <button class="action-btn" id="btn-undo" title="Undo">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>
        Undo
      </button>
      <button class="action-btn" id="btn-clear" title="Clear">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6m4-6v6"/></svg>
        Clear
      </button>
      <button class="action-btn" id="btn-download" title="Download PNG">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
        Save
      </button>
    </div>
  </div>
  <div class="canvas-wrap">
    <canvas id="mainCanvas"></canvas>
    <canvas id="overlayCanvas"></canvas>
  </div>
  <div class="status-bar">
    <span id="toolLabel">Brush</span>
    <span id="sizeLabel">Size: 6px</span>
    <span id="posLabel">x: 0, y: 0</span>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Pointer Events API: unified mouse, touch and pen input with setPointerCapture for uninterrupted strokes
Dual-canvas overlay: a separate preview canvas renders the rubber-band line without touching the artwork
Composite operations: the eraser uses destination-out globalCompositeOperation to remove pixels cleanly
Flood fill: an iterative stack-based getImageData/putImageData algorithm fills enclosed regions by color match
Undo stack: bounded history of toDataURL snapshots restored via drawImage
PNG export: download the artwork through a data-URL anchor with the download attribute
High-DPI coordinate mapping: getBoundingClientRect scaling keeps strokes under the cursor at any size
Resize persistence: artwork is snapshotted and repainted when the window changes size
Adjustable brush: live color picker and 2–40px size slider with round line caps and joins
Zero dependencies: pure HTML, CSS and Canvas 2D API with no external libraries

About this UI Snippet

How to Build a Drawing Canvas with HTML5 Canvas and JavaScript

Screenshot of the Drawing Canvas snippet rendered live

A drawing canvas is a paint-style sketch surface where the user draws freehand strokes with the mouse, finger or stylus, switches between tools like brush, eraser, line and fill bucket, picks colors and brush sizes, undoes mistakes and exports the result as a PNG. This walkthrough explains exactly how the component is built using the HTML5 Canvas 2D API, the Pointer Events API and a small set of pixel-level algorithms, with no external libraries.

The two-canvas architecture

The board uses two stacked <canvas> elements inside a relatively positioned wrapper. The first, mainCanvas, holds the committed artwork. The second, overlayCanvas, sits directly on top with pointer-events: none and is used only as a live preview surface for the line tool, so a rubber-band line can be redrawn every frame without disturbing the real drawing underneath. Both are absolutely positioned at top: 0; left: 0 and sized to 100% of the wrapper, while their internal bitmap resolution is set in JavaScript to match the CSS pixel size.

A critical detail is the resize handler. Setting canvas.width or canvas.height clears the bitmap, so before resizing we snapshot the current image with mainCanvas.toDataURL(), set the new dimensions, repaint the dark background, then load the snapshot into an Image and drawImage it back. This preserves artwork across window resizes. The 2D context is obtained with getContext('2d'); for components that read pixels frequently you would pass { willReadFrequently: true } to hint the browser to keep the buffer in system memory rather than the GPU.

Pointer Events and coordinate mapping

All input is handled through the Pointer Events API (pointerdown, pointermove, pointerup) rather than separate mouse and touch listeners, so a single code path covers mouse, touch and pen. On pointerdown we call mainCanvas.setPointerCapture(e.pointerId) so the element keeps receiving move and up events even when the pointer leaves the canvas mid-stroke — this prevents broken lines when you drag fast past the edge.

Because the canvas bitmap resolution can differ from its displayed CSS size, raw client coordinates must be scaled. The getPos helper reads getBoundingClientRect() and computes x = (e.clientX - rect.left) * (canvas.width / rect.width), mapping screen space into bitmap space so strokes land under the cursor exactly even on high-DPI displays or after resizing.

The tool state machine

A single tool string variable ('brush', 'eraser', 'line', 'fill') drives behavior. setTool updates this variable, toggles the .active class on the toolbar buttons and updates the status label. On each pointerdown the handler branches on tool:

For brush and eraser, we begin a path with ctx.beginPath() and ctx.moveTo(x, y), then on every pointermove we lineTo the new point and stroke(), building a continuous polyline. lineCap and lineJoin are set to round so segments blend into a smooth stroke. The eraser is simply a brush that sets ctx.globalCompositeOperation = 'destination-out', which makes drawing operations remove existing pixels (turning alpha to zero) instead of painting color over them. The brush resets the mode back to source-over.

The line tool and the overlay

The line tool demonstrates why the overlay exists. On pointerdown we record startX, startY. On pointermove we clear the overlay and draw a fresh preview line from the start point to the current point on overlayCanvas only. The user sees the line stretch and rotate in real time, but the main artwork is untouched. On pointerup we clear the overlay and commit the final line to mainCanvas. This is the classic two-layer rubber-band pattern used in vector editors.

The flood fill algorithm

The fill bucket is the most interesting piece. floodFill grabs the entire pixel buffer with ctx.getImageData(0, 0, w, h), giving a flat Uint8ClampedArray where every pixel occupies four bytes (R, G, B, A). It samples the target color at the clicked pixel using the index formula idx = (py * width + px) * 4. If the target color already equals the fill color, it returns early to avoid an infinite loop.

It then runs an iterative stack-based flood fill (an explicit stack instead of recursion to avoid blowing the call stack on large regions). Starting from the clicked pixel, it pops a coordinate, checks bounds, compares that pixel's four channels against the target color, and if they match, overwrites them with the fill color and pushes the four orthogonal neighbors. When the stack empties, the modified buffer is written back with ctx.putImageData(imgData, 0, 0). The fill color comes from hexToRgba, which slices the hex string and parseInt(..., 16) each channel.

Undo via snapshots

Undo is implemented as a bounded history array of data URLs. Before any destructive action (saveHistory) we push mainCanvas.toDataURL() and shift() off the oldest entry once we exceed MAX_HISTORY (20) to cap memory. Undo pop()s the latest snapshot, loads it into an Image, clears the canvas and drawImages it back. Storing full PNG snapshots is simple and robust; a more memory-efficient alternative would store ImageData and putImageData, trading higher RAM for instant restores with no decode step.

Exporting the drawing

Download uses the canvas-to-anchor pattern: create an <a> element, set a.download = 'drawing.png' and a.href = mainCanvas.toDataURL(), then call a.click() to trigger the browser save dialog. toDataURL() defaults to PNG which preserves transparency from the eraser. For very large canvases, canvas.toBlob() with URL.createObjectURL() avoids building a huge base64 string in memory.

Together these techniques — stacked canvases, pointer capture, composite operations, an explicit-stack flood fill and data-URL snapshots — produce a fully functional, dependency-free drawing board you can drop into any page.

Step by step

How to Use

  1. 1
    Pick a toolClick brush, eraser, line or fill bucket in the toolbar. The active tool is highlighted and shown in the status bar.
  2. 2
    Set color and sizeChoose a color with the native color input and drag the size slider to set stroke width from 2 to 40 pixels.
  3. 3
    Draw on the canvasPress and drag to paint freehand. For the line tool, drag to preview a straight line, then release to commit it.
  4. 4
    Fill regionsSelect the fill bucket and click any enclosed area to flood it with the current color using a pixel-matching algorithm.
  5. 5
    Undo or clearUse Undo to step back through the last 20 snapshots, or Clear to reset the whole board to the background color.
  6. 6
    Export your artClick Save to download the canvas as a PNG via a generated data URL anchor.

Real-world uses

Common Use Cases

Freehand sketch pad
Let users doodle and annotate directly in the browser, pairing well with an image filter editor for post-processing.
Whiteboard mockups
Quick wireframing and diagramming where straight lines and fills matter more than precision vector tooling.
Signature capture
Collect handwritten signatures on forms using the brush tool and export the result as a PNG.
Drawing games
Power Pictionary-style guessing games or kids drawing apps with simple, fast tools.
Teaching Canvas APIs
A compact reference for learning getImageData, putImageData and composite operations alongside a pattern lock demo.
Markup and feedback
Annotate screenshots or images with arrows, circles and highlights before sharing.
Related: KUTE.js SVG Shape Morph
See the KUTE.js SVG Shape Morph for a related animations pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The overlay canvas previews the in-progress line tool every frame without redrawing or corrupting the committed artwork. When the pointer is released, the final line is drawn once onto the main canvas and the overlay is cleared.

It sets ctx.globalCompositeOperation to destination-out. In that mode, drawing operations subtract alpha from existing pixels instead of painting color, effectively erasing. The brush resets the mode back to source-over.

Each snapshot is a full PNG captured with toDataURL, which is simple and reliable. Restoring loads the image and redraws it. For lower memory you could store ImageData objects and use putImageData instead, at the cost of higher RAM usage.

The flood fill matches exact RGBA values. Anti-aliased stroke edges contain semi-transparent pixels that do not match the target color, so fills can bleed through soft boundaries. Drawing fully opaque outlines or adding a color tolerance threshold avoids this.

The pointer coordinates are scaled by canvas.width / boundingRect.width, mapping CSS pixels into the canvas bitmap resolution so the painted point always lands directly under the cursor.

Yes. The JSX, Vue, Angular, and Tailwind exports convert the markup automatically. In React, grab the canvas through a ref, set up the 2D context and pointer listeners in useEffect with cleanup, and keep tool, colour, and brush size in state read by the stroke handlers.