Building a Web Coloring Book for Kids with SVG
Years ago, I had a job building coloring book content for a children’s website. It was a tablet-friendly coloring app: tap a path to color it, stamp stickers, and download the finished image.
I wrote it in jQuery back then, but the underlying mechanism is remarkably simple. Here’s how I’d write it today in vanilla JS.
The Core Idea
SVG coloring is surprisingly straightforward.
- Assign classes to each SVG shape element
- When clicked, change that element’s
fill
That’s it. It behaves essentially like a bucket fill tool — click inside a closed path, and the entire path gets colored.
Why It’s So Simple
A paint app’s bucket tool needs to find “adjacent pixels of the same color” at the pixel level — fairly heavy processing.
SVG coloring, on the other hand, has paths pre-defined as “this is hair,” “this is clothing,” etc. The line art is already the region boundary, so you just change the fill of whichever element was clicked.
Implementation
HTML Structure
<div class="canvas-container">
<!-- SVG goes here -->
</div>
<div class="palette">
<button class="color-btn" data-color="#E50012" style="background: #E50012"></button>
<button class="color-btn" data-color="#F9BE00" style="background: #F9BE00"></button>
<button class="color-btn" data-color="#6FB92C" style="background: #6FB92C"></button>
<button class="color-btn" data-color="#00AAD3" style="background: #00AAD3"></button>
<!-- ... -->
</div>
Initialization: Assigning Classes to SVG Shapes
Once the SVG is loaded, assign sequential classes to all colorable shape elements.
function initSvg(container) {
const shapes = container.querySelectorAll(
'svg rect, svg circle, svg ellipse, svg polygon, svg path'
);
shapes.forEach((shape, index) => {
shape.classList.add('fillable', `shape-${index}`);
});
}
fillable is the shared class for colorable elements; shape-${index} is an identifier for history tracking.
Coloring: Change fill on Click
let currentColor = '#E50012';
function setupColorPicker(palette) {
palette.querySelectorAll('.color-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentColor = btn.dataset.color;
});
});
}
function setupFill(container, historyManager) {
container.addEventListener('click', (e) => {
const shape = e.target.closest('.fillable');
if (!shape) return;
const className = [...shape.classList].find(c => c.startsWith('shape-'));
const prevColor = shape.style.fill || getComputedStyle(shape).fill;
shape.style.fill = currentColor;
historyManager.push({
type: 'fill',
className,
prevColor,
newColor: currentColor
});
});
}
e.target.closest('.fillable') checks whether the clicked element is a colorable target. If so, style.fill gets updated.
Undo
Track history in an array; on undo, restore the previous color.
function createHistoryManager() {
const history = [];
return {
push(action) {
history.push(action);
},
undo(container) {
const action = history.pop();
if (!action) return;
switch (action.type) {
case 'fill': {
const shape = container.querySelector(`.${action.className}`);
if (shape) shape.style.fill = action.prevColor;
break;
}
case 'stamp': {
const stamp = document.getElementById(action.id);
if (stamp) stamp.remove();
break;
}
}
}
};
}
Stamp Feature
Stamps add an <image> element inside the SVG.
let currentStamp = null;
function setupStampPicker(picker) {
picker.querySelectorAll('.stamp-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentStamp = btn.dataset.stamp; // image URL etc.
});
});
}
function setupStamp(container, historyManager) {
const svg = container.querySelector('svg');
svg.addEventListener('click', (e) => {
if (!currentStamp) return;
if (e.target.closest('.fillable')) return; // fill takes priority
const rect = svg.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const id = `stamp-${Date.now()}`;
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('id', id);
image.setAttribute('href', currentStamp);
image.setAttribute('x', x - 25); // center it
image.setAttribute('y', y - 25);
image.setAttribute('width', 50);
image.setAttribute('height', 50);
svg.appendChild(image);
historyManager.push({
type: 'stamp',
id
});
});
}
PNG Export
Draw the SVG onto a Canvas and download as PNG.
function downloadAsPng(container) {
const svg = container.querySelector('svg');
const svgData = new XMLSerializer().serializeToString(svg);
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUrl = `data:image/svg+xml;charset=utf-8;base64,${svgBase64}`;
const canvas = document.createElement('canvas');
canvas.width = svg.width.baseVal.value;
canvas.height = svg.height.baseVal.value;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0);
const link = document.createElement('a');
link.download = 'nurie.png';
link.href = canvas.toDataURL('image/png');
link.click();
};
img.src = dataUrl;
}
Extension: Combining with Canvas
The SVG “click a path to color it” approach works like a bucket tool. You might also want freehand drawing — for that, layer a transparent Canvas on top of the SVG.
<div class="canvas-container" style="position: relative;">
<svg><!-- coloring SVG --></svg>
<canvas
style="position: absolute; top: 0; left: 0; pointer-events: none;"
></canvas>
</div>
- SVG layer: click a path → change fill (bucket-style)
- Canvas layer: draw freely with a brush
By toggling pointer-events on tool switch, you control which layer receives clicks.
For Canvas brush drawing and flood fill (bucket), check out the Paint Lab for a working implementation.
The SVG coloring mechanism is just “change the fill of the clicked path.” Since the line art already divides the image into paths, there’s no need for pixel-level region detection.
This was for children’s content, so complex features weren’t needed — the simple approach was more than sufficient. Combining it with Canvas drawing is an interesting extension.