Building a browser-based design tool sounds straightforward — until you realize users expect Photoshop-level control, instant previews, and multi-format exports, all without installing anything. Having spent time building an online stamp maker, I want to walk through the real technical challenges behind this kind of product and how we solved them.
- The Core Rendering Decision: Canvas vs SVG
The first architectural decision in any browser-based design tool is the rendering engine. You have two primary options: HTML5 Canvas and SVG DOM. Each has a fundamentally different model.
Canvas is a pixel-based imperative API. You draw to it with commands:
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(200, 200, 180, 0, Math.PI * 2);
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 4;
ctx.stroke();
The canvas doesn't know about the circle after drawing it. It's pixels on a bitmap. This makes it fast for rendering complex scenes, but terrible for interaction — you have to implement your own hit detection.
SVG is a retained-mode vector format. Every shape is a DOM node you can query, style, and modify:
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', 200);
circle.setAttribute('cy', 200);
circle.setAttribute('r', 180);
circle.setAttribute('stroke', '#1a1a1a');
circle.setAttribute('stroke-width', '4');
svg.appendChild(circle);
The element persists. You can addEventListener on it, animate it, transform it — the DOM does the heavy lifting.
For a stamp maker specifically, SVG wins. Stamps are vector by nature: circles, text, borders, logos. The design must remain editable after placement. And SVG is the primary output format for professional print production. Using Canvas as the primary renderer would mean converting back to SVG for export — a lossy and complex round-trip.
Circular Text: The Hardest UI Problem
The signature feature of any seal or stamp design is text rendered along a circular arc. This looks deceptively simple but involves non-trivial math.
SVG has a native solution: with a
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<path id="top-arc"
d="M 60,200 A 140,140 0 1,1 340,200"
/>
</defs>
<text font-family="Futura, sans-serif" font-size="18" fill="#1a1a1a"
letter-spacing="8">
<textPath href="#top-arc" startOffset="50%" text-anchor="middle">
ACME CORPORATION
</textPath>
</text>
</svg>
The startOffset="50%" with text-anchor="middle" centers the text at the top of the arc. The arc path uses the SVG Arc command (A): center x/y, rx/ry, x-axis-rotation, large-arc-flag, sweep-flag, end x/y.
For the bottom arc (a second line of text that curves the other direction):
<defs>
<path id="bottom-arc"
d="M 60,200 A 140,140 0 0,0 340,200"
/>
</defs>
<text>
<textPath href="#bottom-arc" startOffset="50%" text-anchor="middle">
EST. 2020 · NEW YORK
</textPath>
</text>
The key difference: sweep-flag flips from 1 to 0, reversing the arc direction so text reads naturally at the bottom.
The Letter Spacing Problem at Small Radii
Here's where theory meets reality: letter-spacing in SVG (and CSS) adds spacing after each glyph, measured in straight-line units. On a tight curve, this creates uneven visual spacing — the gap feels wider at the outer edge of each letter and tighter at the inner edge.
There's no CSS property to fix this. The correct solution is to manually place each glyph using textLength and lengthAdjust="spacingAndGlyphs", or better yet, compute per-character rotation and placement programmatically:
function placeTextOnArc(text, cx, cy, radius, startAngle) {
const chars = text.split('');
const angleStep = (2 * Math.PI) / (chars.length * 4); // tune spacing
return chars.map((char, i) => {
const angle = startAngle + (i * angleStep);
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
const rotation = (angle * 180 / Math.PI) + 90;
return `<text x="${x}" y="${y}"
transform="rotate(${rotation}, ${x}, ${y})"
text-anchor="middle">${char}</text>`;
}).join('');
}
This approach gives you per-character control and consistent visual spacing regardless of radius — critical for production-quality output.
Multi-Format Export Architecture
A serious online stamp maker needs to export multiple formats from a single SVG source: PNG, PDF, EPS, SVG, and DOCX. Each has different requirements.
SVG Export
Straightforward — serialize the DOM:
function exportSVG(svgElement) {
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgElement);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
triggerDownload(blob, 'stamp.svg');
}
One caveat: inline fonts. If your SVG uses a custom web font loaded via @font-face, the exported SVG won't embed that font. You need to either:
Convert text to paths (font-to-path conversion, handled server-side with libraries like opentype.js)
Embed the font as a base64 data URI inside the SVG
PNG Export
Use an element as a bridge to :
async function exportPNG(svgElement, scale = 3) {
const svgString = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.src = url;
await new Promise(resolve => img.onload = resolve);
const canvas = document.createElement('canvas');
const size = svgElement.viewBox.baseVal;
canvas.width = size.width * scale; // 3× for 300 DPI equivalent
canvas.height = size.height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => triggerDownload(blob, 'stamp.png'), 'image/png');
URL.revokeObjectURL(url);
}
The scale = 3 multiplier is critical — rendering at 3× the display size gives you ~300 DPI output when printed at the stamp's physical dimensions. Without this, the exported PNG will look pixelated in documents.
PDF Export
Browser-side PDF generation is handled by libraries like jsPDF or pdf-lib. The cleanest approach is to embed the PNG raster inside the PDF rather than attempting SVG-to-PDF vector conversion:
import { jsPDF } from 'jspdf';
async function exportPDF(svgElement) {
const pngDataUrl = await svgToPNGDataURL(svgElement, 4); // 4× scale
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: [50, 50] // 50×50mm page for a stamp
});
pdf.addImage(pngDataUrl, 'PNG', 0, 0, 50, 50);
pdf.save('stamp.pdf');
}
For true vector PDF output (needed for professional print), server-side rendering with tools like Puppeteer (headless Chrome) or CairoSVG is more reliable.
State Management for the Design Editor
A stamp editor needs to track the full design state: border styles, text content, font choices, sizes, colors, logo images. This state must be:
1.Reactive — UI updates instantly as properties change
2.Serializable — saveable to JSON for session persistence
3.Undoable — users expect Ctrl+Z
A simple command pattern works well here:
class StampEditor {
constructor() {
this.state = {
outerText: 'COMPANY NAME',
innerText: 'EST. 2020',
fontSize: 16,
letterSpacing: 8,
borderWidth: 3,
borderStyle: 'double',
inkColor: '#1a1a1a'
};
this.history = [];
this.redoStack = [];
}
update(patch) {
this.history.push({ ...this.state });
this.redoStack = [];
this.state = { ...this.state, ...patch };
this.render();
}
undo() {
if (!this.history.length) return;
this.redoStack.push({ ...this.state });
this.state = this.history.pop();
this.render();
}
}
The immutable update pattern (spreading into a new object) is crucial — it ensures each history entry is a clean snapshot, not a reference to a mutated object.
Real-World Lessons
After building and iterating on Stampdy as a production stamp maker, a few things stand out:
Font rendering inconsistency across browsers is the biggest pain point. Chrome, Firefox, and Safari all render the same SVG font slightly differently — especially at small sizes. The safest fix is to pre-render text-heavy stamps to PNG on the server using a headless browser and serve that as the preview.
User-uploaded logos require aggressive sanitization. SVGs can contain tags and external resource references. Never render user-uploaded SVG directly into the document — parse it, strip dangerous elements, then embed.</p> <p>Performance on mobile degrades fast with complex SVGs. Profile with Chrome DevTools and look at layout/paint time for the SVG element. In practice, stamps with more than 500 path nodes need Canvas fallback on mobile.</p>
Top comments (0)