Creating a Javascript Drawing and Annotation Application
📣 Sponsor
Annotation and drawing have many use cases. In this tutorial let's look at how to make a simple drawing application with Javascript. We'll be building this in pure Javascript, so it will give you the flexibility to implement it wherever you need to.
Demo
To get started, let's look at how it works, I've added functionality to this page. When you click the button, you will be provided drawing tools, which you can use to draw on the page with lines and arrows. There is also an eraser tool, to erase lines by clicking on them should you make mistakes.
The drawing tools can be closed by clicking the cross button within the drawing box. As well, all drawings are maintained on the page afterward, allowing you to annotate this page.
Tracking User's Mouse Activity
As mentioned before, this demo uses vanilla JS, so to make it work we focus on using a few event listeners which will track what the user is doing.
To track user activity, I have created 3 objects - one is a general config item, and the other two relate to the pencil and arrow drawing tools respectively. These configs are shown below along with explanations for each item in the comments.
let config = {
    drawing: false,         // Set to true if we are drawing, false if we aren't
    tool: 'freeHand',       // The currently selected tool
	color : 'white',        // The currently selected colour
    strokeWidth: 4,         // The width of the lines we draw
    configNormalisation: 12,// The average normalisation for pencil drawing
}
let arrow = {
    // topX, Y, and bottomX, Y store information on the arrows top and bottom ends
	topX: 0,
	topY: 0,
	bottomX: 0,      
	bottomY: 0,          
	activeDirection: 'se',                    // This is the current direction of the arrow, i.e. south-east
    arrowClasses: [ 'nw', 'ne', 'sw', 'se' ], // These are possible arrow directions
	lineAngle: 0,                             // This is the angle the arrow point at about the starting point
}
let freeHand = {
	currentPathText: 'M0 0 ',      // This is the current path of the pencil line, in text
	topX: 0,                       // The starting X coordinate
	topY: 0,                       // The starting Y coordinate
    lastMousePoints: [ [0, 0] ],   // This is the current path of the pencil line, in array
}
Controlling the UI
The next major step is adding a UI. I've created a simple drawing UI which pops up when the user clicks on 'Start Drawing'. In HTML, it looks like this:
<button class="animated hover-button" id="start-drawing"><span>✍️ Activate Drawing</span></button>
<div id="drawing-cover"></div>
<div id="drawing-layer"></div>
<div id="drawing-box">
    <div class="tools">
        <button data-tool="freeHand" data-current="true"><span>Pen</span></button>
        <button data-tool="arrow"><span>Arrow</span></button>
        <button data-tool="eraser"><span>Eraser</span></button>
    </div>
    <div class="colors">
        <div data-color="white" data-rColor="white" data-current="true"></div>
        <div data-color="black" data-rColor="#544141"></div>
        <div data-color="red" data-rColor="#d83030"></div>
        <div data-color="green" data-rColor="#30d97d"></div>
        <div data-color="orange" data-rColor="#ff9000"></div>
        <div data-color="yellow" data-rColor="f3f326"></div>
    </div>
    <div class="close">
        Close
    </div>
</div>
Note: I have added two other elements which sit outside the UI - one is drawing-cover, which simply covers the screen whenever drawing is activated, and the other is drawing-layer, which holds all drawing elements the user draws.
For each tool and color, I have appended some data attributes:
- For tools - each item has a data-tool attribute.
 - For colors - each item has a data-color and data-rColor attribute, referring to the color's textual name and its hex value respectively.
 
We need data attributes because we will reference them in the code when the user clicks on items. Below is the code for controlling the UI - in effect, all we do here is alter our main config object when the user clicks on a tool or color:
// Add a pointerdown event for each color and tool.
// When a user clicks a color or tool, then we set it to our current config.color or config.tool respectively, and highlight it on the UI
[ 'data-rColor', 'data-tool' ].forEach(function(i) {
    document.querySelectorAll(`[${i}]`).forEach(function(item) {
        item.addEventListener('pointerdown', function(e) {
            document.querySelectorAll(`[${i}]`).forEach(function(i) {
                i.setAttribute('data-current', false);
            });
            item.setAttribute('data-current', true);
            if(i == 'data-rColor') {
                config.color = item.getAttribute(i);
            } else if(i == 'data-tool') {
                config.tool = item.getAttribute(i);
            }
        });
    });
});
// Set the body attribute 'data-drawing' to true or false, based on if the user clicks the 'Start Drawing' button
// Also sets config.drawing to true or false.
document.getElementById('start-drawing').addEventListener('click', function(e) {
    if(config.drawing === true) {
        config.drawing = false;
        document.body.setAttribute('data-drawing', false)
    } else {   
        let drawingCover = document.getElementById('drawing-cover');
        document.body.setAttribute('data-drawing', true)
        config.drawing = true;
    }
});
// Closes the drawing box and sets 'data-drawing' on the body element to false
// Along with cofig.drawing to false.
document.querySelector('#drawing-box .close').addEventListener('click', function(e) {
    document.body.setAttribute('data-drawing', false);
    config.drawing = false;
})
HTML Elements for Our Drawings
To draw, we must append something to our user's screen. To make things easy and modular, I am using SVG. Based on your use case, this could also be rewritten with canvas if required - however SVG works fine too. SVG gives us a good amount of control over the DOM elements should we need it. I have created two functions that return HTML for arrows and line drawings. These are shown below.
As we need to pass some numbers to these HTML elements, we have variables for these functions (i.e. start, dimensions, path, etc.. This allows us to give updated positioning to our SVG tags, whch we can render onto the page.
let svgEl = {
	arrowPath: (start, dimensions, path, dummy, direction, end, angle, hyp, id) => 
	`<div class="arrow drawing-el static current-item" data-id="${id}" data-direction="${direction}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;">
		<div class="arrow-point arrow-point-one"></div>
		<div class="arrow-point arrow-point-two" style="
			transform-origin: 0 0; left: ${hyp[1]}px; top: ${hyp[2]}px; transform: rotateZ(${angle}deg) translateY(-${hyp[0]}px) translateX(-15px);
		"></div>
		<svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}">
			<defs>
				<marker id="arrow-head-${id}" class="arrow-resizer" markerWidth="10" markerHeight="10" refX="0" refY="3" orient="auto" markerUnits="strokeWidth" viewBox="0 0 20 20">
					<path d="M0 0 L0 6 L9 3 z" fill="${config.color}" />
				</marker>
			</defs>
			<path marker-start="url(#bottom-marker)" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" marker-end="url(#arrow-head-${id})" class="arrow-line" d="${path}" />
		</svg>
	</div>`,
	drawPath: (start, dimensions, path, id) => 
	`<div class="free-hand drawing-el static current-item" data-id="${id}" style="left: ${start[0]}px; top: ${start[1]}px; height: ${dimensions[1]}px; width: ${dimensions[0]}px;">
		<svg viewbox="0 0 ${dimensions[0]} ${dimensions[1]}">			
			<path d="${path}" style="stroke: ${config.color}; stroke-width: ${config.strokeWidth}" />
		</svg>
	</div>`
}
These two functions give us the right SVGs for arrows and free hand drawing.
User Interaction
The final step is adding in user interaction. Ultimately, this boils down to three main functions:
- a 
mousedown, to tell us when the user has initiated drawing, assuming they have clicked the 'Start Drawing' button. - a 
mousemove, to track a user's mouse movement. - a 
mouseup, for when the user completes drawing. 
Step 1: Mousedown
The first stage is mousedown. This will trigger any time the user clicks on our webpage. As such, we want to make sure the user is drawing (i.e. config.drawing is true). We can do that by checking our config object to see if config.drawing is set to true. If we are drawing, then we store the initial point the user clicks in either the freeHand or arrow config objects.
Finally, we append the HTML elements to the page. If we're using the eraser we check if the point the user clicked on is an SVG and delete it if clicked on. For that, we use the parent helper function, which can be found in the Github Repo or on our ">Codepen example.
document.body.addEventListener('pointerdown', function(e) {
    
    // Generate id for each element
    let id = helper.generateId();
    if(config.tool == 'arrow' && config.drawing == true) {
        // Set arrow start point
        arrow.topX = e.clientX;
        arrow.topY = e.clientY;
        // Add element to drawing layer
		document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + 
        svgEl.arrowPath(  [ arrow.topX + window.scrollX, arrow.topY + window.scrollY ], [  e.clientX, e.clientX ], `M0 0 L0 0`, 'arrow-item', arrow.arrowClasses[3], [ 0, 0 ], 0, [ 0, 0, 0 ], id );
    }
    else if(config.tool == 'freeHand' && config.drawing == true) {
        // Set the drawing starting point
        freeHand.topX = e.clientX;
        freeHand.topY = e.clientY;
        // Set the current path and most recent mouse points to whereever we are scrolled on the page
        freeHand.currentPathText = `M${window.scrollX} ${window.scrollY} `;
        freeHand.lastMousePoints = [[ window.scrollX, window.scrollY ]];
        
        // Add element to the drawing layer
        document.getElementById('drawing-layer').innerHTML = document.getElementById('drawing-layer').innerHTML + 
        svgEl.drawPath( [ e.clientX, e.clientY ], [ e.clientX, e.clientY ], ``, id);
    } 
    else if(config.tool == 'eraser' && config.drawing == true) {
        // Check if user has clicked on an svg
        if(helper.parent(e.target, '.drawing-el', 1) !== null && helper.parent(e.target, '.drawing-el', 1).matches('.drawing-el')) {
            // If they have, delete it
            helper.parent(e.target, '.drawing-el', 1).remove();
        }
    }
})
Step 2: Mousemove
Next let's work on what happens whenever the user has clicked, and then moves their mouse. In this situation, we want to extend the line for free hand, or move the arrow head for arrows. The currently drawn element has a class called current-item, so we can use this to update our HTML element. Fundamentally, we just want to add more points to our SVG element based on where the user's mouse goes. Since we stored the original position the user clicked in our config we can use this as a reference point to figure out how many pixels the user has moved from there. To do that, we also use two calculation helper functions, both of which can be found in the Github Repo or on our codepen example:
- For arrows, we use 
calculateArrowLineAngleto calculate the angle and direction of the arrow. - For free hand, we use 
getAveragePointto calculate the average of the last few mouse movements, to create a smooth line. 
Upon moving, we also remove the class static from the drawn elements. This lets us know the user wants to keep this drawn element. If they didn't move, we would later remove it when they lift their finger off the mouse, and the static class lets us determine that.
document.body.addEventListener('pointermove', function(e) {
    // Assuming there is a current item to in the drawing layer
    if(document.querySelector('#drawing-layer .current-item') !== null) {
        // If we are using the arrow tool
        if(config.drawing == true && config.tool == 'arrow') {
            // Then get the original start position
            let startX = arrow.topX;
            let startY = arrow.topY;
            // Set a default angle of 90
            let angleStart = 90;
            
            // And a default direction of 'south east'
            let arrowClass = arrow.arrowClasses[3];
            // Calculate how far the user has moved their mouse from the original position
            let endX = e.pageX - startX - window.scrollX;
            let endY = e.pageY - startY - window.scrollY;
            // And using that info, calculate the arrow's angle
            helper.calculateArrowLineAngle(endX, endY);
            // Then update the config to this new end position
            arrow.bottomX = endX;
            arrow.bottomY = endY;
            
            // And update the HTML to show the new arrow to the user
            document.querySelector('#drawing-layer .arrow.current-item').classList.remove('static');
            document.querySelector('#drawing-layer .arrow.current-item').setAttribute('data-direction', arrow.activeDirection);
            document.querySelector('#drawing-layer .arrow.current-item svg').setAttribute('viewbox', `0 ${endX} 0 ${endY}`);
            document.querySelector('#drawing-layer .arrow.current-item path.arrow-line').setAttribute('d', `M0 0 L${endX} ${endY}`);
        }
        
        else if(config.drawing == true && config.tool == 'freeHand') {
            // Similar to arrows, calculate the user's end position
            let endX = e.pageX - freeHand.topX;
            let endY = e.pageY - freeHand.topY;
            
            // And push these new coordinates to our config
            let newCoordinates = [ endX, endY ];
            freeHand.lastMousePoints.push([endX, endY]);
            if(freeHand.lastMousePoints.length >= config.configNormalisation) {
                freeHand.lastMousePoints.shift();
            }
            
            // Then calculate the average points to display a line to the user
            let avgPoint = helper.getAveragePoint(0);
            if (avgPoint) {
                freeHand.currentPathText += " L" + avgPoint.x + " " + avgPoint.y;
                let tmpPath = '';
                for (let offset = 2; offset < freeHand.lastMousePoints.length; offset += 2) {
                    avgPoint = helper.getAveragePoint(offset);
                    tmpPath += " L" + avgPoint.x + " " + avgPoint.y;
                }
                // Set the complete current path coordinates
                document.querySelector('#drawing-layer .free-hand.current-item').classList.remove('static');
                document.querySelector('#drawing-layer .free-hand.current-item svg path').setAttribute('d', freeHand.currentPathText + tmpPath);
            }
        }
    }
		
});
Step 3: Mouseup
The point of mouse up is to a) reset the drawing configuation for freeHand and arrow and b) remove any elements where the user didn't move their mouse. If we don't do b), then random arrow heads will appear as the user clicks on the page.
This is relatively simple compared to the other functions, and looks like this:
// Whenever the user leaves the page with their mouse or lifts up their cursor
[ 'mouseleave', 'pointerup' ].forEach(function(item) {
    document.body.addEventListener(item, function(e) {
        // Remove current-item class from all elements, and give all SVG elements pointer-events
        document.querySelectorAll('#drawing-layer > div').forEach(function(item) {
            item.style.pointerEvent = 'all';
            item.classList.remove('current-item');
            // Delete any 'static' elements
            if(item.classList.contains('static')) {
                item.remove();
            }
        });
        // Reset freeHand variables where needed
        freeHand.currentPathText = 'M0 0 ';
        freeHand.lastMousePoints = [ [0, 0] ];
    });
});
Conclusion
And we're done. Since we've used pointerdown, pointermove and pointerup, this demo should also work on mobile. Below, I've attached some useful links, including the source code on Github and Codepen. If you have any questions, you can reach us on Twitter.
More Tips and Tricks for CSS
- iOS Crystalline Blurred Backgrounds with CSS Backdrop Filters
 - CSS Individual Transform Properties
 - Stripe-Like Smooth Transition CSS Only Menu
 - How to do Deletion Animations with CSS
 - How to Animate SVG paths with CSS
 - CSS Fonts
 - How the CSS Box Model Works
 - Centering Elements in CSS with Tailwind
 - Creating a Javascript Drawing and Annotation Application
 - The Quick Guide to Dark Mode